From 5e36c3e4228e231789ba7d2d876db6d1a3e78d28 Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 12 Nov 2026 02:35:20 -0800 Subject: [PATCH 1/2] guest: delete the old layered init and its Azure Linux helpers Since SL6.3, the shell /init hands every layered root to nvx-init, so nothing starts the old layered init, nvx-init-agent, any more. The Azure Linux one-shot container helpers, nvx-container-launch-azurelinux and nvx-container-enter-azurelinux, had no caller either: the Azure Linux initramfs installed them, but never the old init that ran the container launcher. Delete guest/common/nvx-init-agent and the two helpers, with their build steps: the Alpine initramfs no longer installs nvx-init-agent, and the Azure Linux Docker stage no longer installs the helpers. ShellCheck and shfmt, in check-quality and in the setup guide, no longer name them. Tests: - SandboxShareAgentTests and SandboxRootTokenAgentTests, which ran the old init's share and root-token readers, retire, except the two tests of nvx-hostmount, which move to HostmountShareTests and name nvx-hostmount by its own path rather than beside the deleted init: test_hostmount_accepts_only_child_names_inside_the_aggregate and test_hostmount_makes_file_mount_points_without_following_links. - test_managed_executions_enter_through_the_agent_not_the_container_helpers, which read the old init, retires. - GuestInitRoutingTests no longer stubs /sbin/nvx-init-agent, and its routes no longer check that the deleted init does not run. - test_azurelinux_initramfs_uses_locked_busybox_applets no longer reads the deleted Azure Linux launcher. - root-tokens.json drops shell_error, which only the old init's root-token test read, and its description no longer names the init. Docs: the project structure, the Azure Linux guest, nvx-init's rustdoc and the nvx README no longer name the deleted programs. The rustdoc of protocol::root, protocol::export and guest::root no longer describes the checks with which the deleted init read the root tokens and mounted the export. The Ubuntu guest no longer describes the one-shot workloads that the deleted init ran through the Alpine container helpers. Part of #1123 (SL7.8b). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .github/actions/check-quality/action.yml | 6 - doc/guests/azurelinux-guest.md | 2 - doc/guests/ubuntu-guest.md | 15 +- doc/project-structure.md | 24 +- doc/setup.md | 9 - docker/Dockerfile | 5 +- guest/common/nvx-container-enter-azurelinux | 81 -- guest/common/nvx-container-launch-azurelinux | 19 - guest/common/nvx-init-agent | 843 ------------- nvx-rs/README.md | 3 +- nvx-rs/src/guest/init.rs | 4 +- nvx-rs/src/guest/root.rs | 4 +- nvx-rs/src/protocol/export.rs | 7 +- nvx-rs/src/protocol/root.rs | 5 +- nvx-rs/src/protocol/root/tests.rs | 3 +- nvx-rs/tests/fixtures/root-tokens.json | 56 +- scripts/nvx_tools/build.py | 5 - scripts/test_microvm_tests.py | 17 - scripts/test_nvx_tools.py | 1141 +----------------- 19 files changed, 55 insertions(+), 2194 deletions(-) delete mode 100755 guest/common/nvx-container-enter-azurelinux delete mode 100644 guest/common/nvx-container-launch-azurelinux delete mode 100644 guest/common/nvx-init-agent diff --git a/.github/actions/check-quality/action.yml b/.github/actions/check-quality/action.yml index eb56dee78..6c1f9b148 100644 --- a/.github/actions/check-quality/action.yml +++ b/.github/actions/check-quality/action.yml @@ -43,9 +43,6 @@ runs: guest/common/init guest/alpine/nvx-container-enter \ guest/alpine/nvx-container-launch \ guest/common/nvx-hostmount guest/common/nvx-identity-probe \ - guest/common/nvx-init-agent \ - guest/common/nvx-container-enter-azurelinux \ - guest/common/nvx-container-launch-azurelinux \ guest/common/nvx-sandbox-smoke guest/common/nvx-snapshot \ guest/common/nvx-virtio-restore-probe \ scripts/setup/setup-linux.sh @@ -59,9 +56,6 @@ runs: guest/common/init guest/alpine/nvx-container-enter \ guest/alpine/nvx-container-launch \ guest/common/nvx-hostmount guest/common/nvx-identity-probe \ - guest/common/nvx-init-agent \ - guest/common/nvx-container-enter-azurelinux \ - guest/common/nvx-container-launch-azurelinux \ guest/common/nvx-sandbox-smoke guest/common/nvx-snapshot \ guest/common/nvx-virtio-restore-probe \ scripts/setup/setup-linux.sh diff --git a/doc/guests/azurelinux-guest.md b/doc/guests/azurelinux-guest.md index 0d4f22f7c..22c69f013 100644 --- a/doc/guests/azurelinux-guest.md +++ b/doc/guests/azurelinux-guest.md @@ -16,8 +16,6 @@ The Azure Linux initramfs is a workload guest, not a sandbox control environment. Alpine remains the default guest of `run` and the only control environment of `sandbox`: -- the initramfs omits the sandbox init agent, so the common `/init` refuses - sandbox mode; - the microVM test harness rejects the sandbox-control scenarios for Azure Linux; and - `build-distro-layer` accepts only Ubuntu, so there is no Azure Linux diff --git a/doc/guests/ubuntu-guest.md b/doc/guests/ubuntu-guest.md index fd390d0a5..95b078899 100644 --- a/doc/guests/ubuntu-guest.md +++ b/doc/guests/ubuntu-guest.md @@ -147,16 +147,11 @@ proof. The layer runs under the Alpine control initramfs as the sandbox's read-only `distro` layer over a writable scratch disk, which is formatted separately with `mkfs.ext4`; see the -[experimental sandbox](../run.md#experimental-single-workload-sandbox). For a -one-shot workload, the Alpine -[container entry helper](../../guest/alpine/nvx-container-enter) injects its -musl loader, `setpriv`, and libraries into `/.nvx-agent`, so glibc Ubuntu -binaries run while the trusted agent stays musl-based. A managed sandbox's -init, nvx-init, a static binary, runs each execution itself, as a plain -process in the VM's root, without them. A one-shot workload gets private -mount, PID, and UTS namespaces, a managed sandbox's executions share -nvx-init's, and every workload shares the guest's network namespace. It runs -as a fixed non-root identity, by default `65534:65534`, the Ubuntu `nobody` +[experimental sandbox](../run.md#experimental-single-workload-sandbox). The +sandbox's init, nvx-init, a static binary, runs each execution itself, as a +plain process in the VM's root. Its executions share nvx-init's mount, PID, +and UTS namespaces, and the guest's network namespace. A workload runs as a +fixed non-root identity, by default `65534:65534`, the Ubuntu `nobody` account whose home the builder creates, with no supplementary groups, no capabilities, and `no_new_privs`. diff --git a/doc/project-structure.md b/doc/project-structure.md index 661638709..bf0907ccc 100644 --- a/doc/project-structure.md +++ b/doc/project-structure.md @@ -227,10 +227,9 @@ verification of the pinned OpenVMM release. ### `guest/`, `ubuntu/`, and `azurelinux/` `guest/common` contains scripts shared by the Alpine, Ubuntu, and Azure Linux -initramfs builds, plus the Azure Linux variants of the container-entry -scripts. The ten compiled `/sbin/nvx-*` programs live under `nvx-rs/src/guest` -and `nvx-rs/src/bin`. `init` controls early boot and then starts an -initramfs program named by `nvx_exec=`, `nvx-init` for a layered root, +initramfs builds. The ten compiled `/sbin/nvx-*` programs live under +`nvx-rs/src/guest` and `nvx-rs/src/bin`. `init` controls early boot and then +starts an initramfs program named by `nvx_exec=`, `nvx-init` for a layered root, `nvx-managed-agent` for a managed workload, a one-shot workload named by `nvx_entrypoint=`, a virtio restore probe for benchmark profiling, or the interactive shell. On an interactive boot, `init` prints a @@ -240,19 +239,10 @@ the container launch and entry scripts that only the Alpine initramfs installs; the entry script stages Alpine's musl loader and `setpriv` inside the workload's root. -The older sandbox scripts, `nvx-init-agent` and the container scripts, which -`init` no longer starts, resolve fixed virtio-blk roles through sysfs, mount the -EROFS `distro` layer as the overlay's only lower layer, under the upper that -the host declares, an ext4 scratch disk or a tmpfs, place the workload in its -cgroup before release, construct its mount/PID/UTS namespaces, enter its -filesystem root after dropping capabilities, and retain the agent as the outer -PID 1. In a managed sandbox, the agent supervises `nvx-managed-agent`, which -enters each execution itself through the library's `nvx::guest::entry`, and -performs the ordered unmount teardown after a stop request. The remaining -common helpers handle shutdown, virtio-fs mounting, snapshot preparation, and -the guest side of the time ABI. The Rust guest binaries provide shutdown, -managed control, restore repair, device access, ingress announcements, and -diagnostic probes. +The remaining common helpers handle shutdown, virtio-fs mounting, snapshot +preparation, and the guest side of the time ABI. The Rust guest binaries +provide shutdown, managed control, restore repair, device access, ingress +announcements, and diagnostic probes. `ubuntu/packages.lock.json` pins the complete supplemental `.deb` closure installed without maintainer-script execution. diff --git a/doc/setup.md b/doc/setup.md index ffe0ea68a..56cda6a83 100644 --- a/doc/setup.md +++ b/doc/setup.md @@ -219,9 +219,6 @@ shellcheck --shell=sh \ guest/common/init guest/alpine/nvx-container-enter \ guest/alpine/nvx-container-launch \ guest/common/nvx-hostmount guest/common/nvx-identity-probe \ - guest/common/nvx-init-agent \ - guest/common/nvx-container-enter-azurelinux \ - guest/common/nvx-container-launch-azurelinux \ guest/common/nvx-sandbox-smoke \ guest/common/nvx-snapshot guest/common/nvx-virtio-restore-probe \ scripts/setup/setup-linux.sh @@ -234,9 +231,6 @@ shfmt -d -ln posix -i 4 -ci \ guest/common/init guest/alpine/nvx-container-enter \ guest/alpine/nvx-container-launch \ guest/common/nvx-hostmount guest/common/nvx-identity-probe \ - guest/common/nvx-init-agent \ - guest/common/nvx-container-enter-azurelinux \ - guest/common/nvx-container-launch-azurelinux \ guest/common/nvx-sandbox-smoke \ guest/common/nvx-snapshot guest/common/nvx-virtio-restore-probe \ scripts/setup/setup-linux.sh @@ -282,9 +276,6 @@ shfmt -w -ln posix -i 4 -ci \ guest/common/init guest/alpine/nvx-container-enter \ guest/alpine/nvx-container-launch \ guest/common/nvx-hostmount guest/common/nvx-identity-probe \ - guest/common/nvx-init-agent \ - guest/common/nvx-container-enter-azurelinux \ - guest/common/nvx-container-launch-azurelinux \ guest/common/nvx-sandbox-smoke \ guest/common/nvx-snapshot guest/common/nvx-virtio-restore-probe \ scripts/setup/setup-linux.sh diff --git a/docker/Dockerfile b/docker/Dockerfile index d8189cdd7..4a220e3cb 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -131,10 +131,7 @@ install -m 0755 /repo/guest/common/init /rootfs/init install -m 0755 /repo/guest/common/nvx-hostmount \ /repo/guest/common/nvx-identity-probe \ /repo/guest/common/nvx-snapshot \ - /repo/guest/common/nvx-virtio-restore-probe \ - /repo/guest/common/nvx-container-enter-azurelinux /rootfs/sbin/ -install -m 0755 /repo/guest/common/nvx-container-launch-azurelinux \ - /rootfs/sbin/nvx-container-launch + /repo/guest/common/nvx-virtio-restore-probe /rootfs/sbin/ python3 <<'PY' import shutil import sys diff --git a/guest/common/nvx-container-enter-azurelinux b/guest/common/nvx-container-enter-azurelinux deleted file mode 100755 index 4834e8e26..000000000 --- a/guest/common/nvx-container-enter-azurelinux +++ /dev/null @@ -1,81 +0,0 @@ -#!/bin/sh -set -eu - -rootfs=$1 -hostname_value=$2 -workload_uid=$3 -workload_gid=$4 -workload_name=$5 -workload_home=$6 -shift 6 -tools=$rootfs/.nvx-agent -loader=/lib64/ld-linux-x86-64.so.2 - -mount --make-rprivate / -rm -rf "$tools" -mkdir -p \ - "$tools" \ - "$rootfs/dev" \ - "$rootfs/proc" \ - "$rootfs/sys" -mount -t tmpfs -o mode=755,nosuid,nodev tmpfs "$tools" -cp -L "$loader" "$tools/ld-linux-x86-64.so.2" -cp /usr/bin/setpriv "$tools/setpriv" -# Extract `name => /path` mappings and bare `/path` loader entries. -ldd /usr/bin/setpriv | - sed -n '/=>/ { s/.*=> \(\/[^ ]*\).*/\1/; p; d; }; s/^[[:space:]]*\(\/[^ ]*\).*/\1/p' | - while IFS= read -r library; do - if [ "$library" = "$loader" ]; then - continue - fi - cp -L "$library" "$tools/$(basename "$library")" - done -chmod 0555 "$tools"/* -mount -o remount,ro,nosuid,nodev "$tools" -mount -t proc -o nosuid,nodev,noexec proc "$rootfs/proc" -mount -t sysfs -o ro,nosuid,nodev,noexec sysfs "$rootfs/sys" -mount -t tmpfs -o mode=755,nosuid tmpfs "$rootfs/dev" -mkdir -p "$rootfs/dev/pts" "$rootfs/dev/shm" -mount -t devpts -o newinstance,ptmxmode=0666,mode=0620 devpts "$rootfs/dev/pts" -mount -t tmpfs -o mode=1777,nosuid,nodev tmpfs "$rootfs/dev/shm" - -workload_machine_id=/run/nvx/workload-machine-id -if [ ! -f "$workload_machine_id" ] || [ -L "$workload_machine_id" ]; then - echo "invalid workload machine ID: $workload_machine_id" >&2 - exit 125 -fi -if [ -L "$rootfs/etc" ] || { [ -e "$rootfs/etc" ] && [ ! -d "$rootfs/etc" ]; }; then - echo "workload rootfs has an invalid /etc directory" >&2 - exit 125 -fi -mkdir -p "$rootfs/etc" -rm -f "$rootfs/etc/machine-id" -: >"$rootfs/etc/machine-id" -mount --bind "$workload_machine_id" "$rootfs/etc/machine-id" -mount -o remount,bind,ro,nosuid,nodev "$rootfs/etc/machine-id" - -mknod -m 666 "$rootfs/dev/null" c 1 3 -mknod -m 666 "$rootfs/dev/zero" c 1 5 -mknod -m 666 "$rootfs/dev/full" c 1 7 -mknod -m 666 "$rootfs/dev/random" c 1 8 -mknod -m 666 "$rootfs/dev/urandom" c 1 9 -mknod -m 666 "$rootfs/dev/tty" c 5 0 -ln -s pts/ptmx "$rootfs/dev/ptmx" -ln -s /proc/self/fd "$rootfs/dev/fd" -ln -s /proc/self/fd/0 "$rootfs/dev/stdin" -ln -s /proc/self/fd/1 "$rootfs/dev/stdout" -ln -s /proc/self/fd/2 "$rootfs/dev/stderr" - -hostname "$hostname_value" -export HOME="$workload_home" USER="$workload_name" LOGNAME="$workload_name" -exec chroot "$rootfs" /.nvx-agent/ld-linux-x86-64.so.2 \ - --library-path /.nvx-agent \ - /.nvx-agent/setpriv \ - --reuid "$workload_uid" \ - --regid "$workload_gid" \ - --clear-groups \ - --no-new-privs \ - --bounding-set=-all \ - --inh-caps=-all \ - --ambient-caps=-all \ - "$@" diff --git a/guest/common/nvx-container-launch-azurelinux b/guest/common/nvx-container-launch-azurelinux deleted file mode 100644 index 684fe56d2..000000000 --- a/guest/common/nvx-container-launch-azurelinux +++ /dev/null @@ -1,19 +0,0 @@ -#!/bin/sh -set -eu - -barrier=$1 -rootfs=$2 -hostname_value=$3 -workload_uid=$4 -workload_gid=$5 -workload_name=$6 -workload_home=$7 -shift 7 - -IFS= read -r start <"$barrier" -[ "$start" = start ] || exit 125 - -exec unshare --mount --pid --uts --fork --kill-child \ - /sbin/nvx-container-enter-azurelinux \ - "$rootfs" "$hostname_value" "$workload_uid" "$workload_gid" \ - "$workload_name" "$workload_home" "$@" diff --git a/guest/common/nvx-init-agent b/guest/common/nvx-init-agent deleted file mode 100644 index 0aa4fa26a..000000000 --- a/guest/common/nvx-init-agent +++ /dev/null @@ -1,843 +0,0 @@ -#!/bin/sh -set -eu - -export PATH=/usr/sbin:/usr/bin:/sbin:/bin HOME=/root TERM=linux -cmdline=$(cat /proc/cmdline) -runtime=/run/nvx -layers=$runtime/layers -scratch=$runtime/scratch -# The mount point of a RAM upper, which nvx::guest::root names alike. -tmpfs=$runtime/tmpfs -rootfs=$runtime/rootfs -# The host export of a managed sandbox goes at $hostfs/root, where only root -# can enter, as in the guest's plain path. -hostfs=$runtime/hostfs -# Mounted live shares, or the host export of a managed sandbox, one per line -# and most recent first, so that teardown unmounts them in reverse mount order. -share_mountpoints= - -unmount_live_shares() { - unmount_status=0 - unmount_ifs=$IFS - IFS=' -' - set -f - for share_mountpoint in $share_mountpoints; do - if ! umount "$share_mountpoint"; then - echo "NVX-SANDBOX-ERROR: failed to unmount the live share at ${share_mountpoint#"$rootfs"}" >&2 - unmount_status=1 - fi - done - set +f - IFS=$unmount_ifs - share_mountpoints= - return "$unmount_status" -} - -fatal() { - echo "NVX-SANDBOX-ERROR: $*" >&2 - unmount_live_shares || true - /sbin/nvx-exit 125 - while :; do - sleep 3600 - done -} - -cmdline_value() { - key=$1 - # Kernel command-line tokens are deliberately split on spaces. - # shellcheck disable=SC2086 - set -f - for token in $cmdline; do - case "$token" in - "$key"=*) - set +f - printf '%s\n' "${token#*=}" - return 0 - ;; - esac - done - set +f - return 1 -} - -resolve_block_device() { - address=$(printf '%d' "$1") || return 1 - attempts=0 - while [ "$attempts" -lt 600 ]; do - while IFS=' ' read -r range separator platform _; do - [ "$separator" = : ] || continue - case "$platform" in virtio-mmio.*) ;; *) continue ;; esac - start=${range%%-*} - start=$(printf '%d' "0x$start") || continue - if [ "$start" -eq "$address" ]; then - for block in "/sys/devices/virtio-mmio-cmdline/$platform"/virtio*/block/*; do - [ -e "$block" ] || continue - printf '/dev/%s\n' "${block##*/}" - return 0 - done - fi - done /sys/fs/cgroup/cgroup.subtree_control || - fatal "failed to enable cgroup controllers" - fi - - mkdir -p /sys/fs/cgroup/agent /sys/fs/cgroup/container - echo "$$" >/sys/fs/cgroup/agent/cgroup.procs || - fatal "failed to place the agent in its cgroup" - - case "$available" in - *" memory "*) - agent_memory_low=$(cmdline_value nvx_agent_memory_low || true) - [ -n "$agent_memory_low" ] || agent_memory_low=16777216 - echo "$agent_memory_low" >/sys/fs/cgroup/agent/memory.low || - fatal "invalid agent memory reservation" - ;; - esac - memory_max=$(cmdline_value nvx_memory_max || true) - if [ -n "$memory_max" ]; then - echo "$memory_max" >/sys/fs/cgroup/container/memory.max || - fatal "invalid container memory limit" - fi - pids_max=$(cmdline_value nvx_pids_max || true) - if [ -n "$pids_max" ]; then - echo "$pids_max" >/sys/fs/cgroup/container/pids.max || - fatal "invalid container process limit" - fi -} - -# The host's process limit, which configure_cgroups applies, holds one more -# process than the workload's for the one-shot launcher's `unshare` parent. -# A managed execution has no such parent, so the container cgroup gives that -# process back. -give_back_launcher_process() { - case "$pids_max" in - '' | *[!0-9]*) ;; - *) - echo "$((pids_max - 1))" >/sys/fs/cgroup/container/pids.max || - fatal "invalid container process limit" - ;; - esac -} - -mount_layer() { - role=$1 - address=$2 - expected_uuid=$3 - expected_sectors=$4 - device=$(resolve_block_device "$address") || - fatal "$role layer at $address did not appear" - name=${device##*/} - - if [ -n "$expected_sectors" ]; then - actual_sectors=$(cat "/sys/class/block/$name/size") - [ "$actual_sectors" = "$expected_sectors" ] || - fatal "$role layer has $actual_sectors sectors, expected $expected_sectors" - fi - - blockdev --flushbufs "$device" || - fatal "failed to drop cached blocks for $role layer" - directory=$layers/$role - mkdir -p "$directory" - mount -t erofs -o ro "$device" "$directory" || - fatal "failed to mount $role layer from $device" - - if [ -n "$expected_uuid" ]; then - actual_uuid=$(blkid -s UUID -o value "$device" || true) - [ "$actual_uuid" = "$expected_uuid" ] || - fatal "$role layer UUID is $actual_uuid, expected $expected_uuid" - fi -} - -# Returns whether $1 is a UUID in its canonical text: 32 lowercase hexadecimal -# digits in groups of 8, 4, 4, 4, and 12, joined by hyphens. -is_canonical_uuid() { - case "$1" in - # The shape fixes four hyphens, so a fifth would stand for a digit. - *[!0-9a-f-]* | *-*-*-*-*-*) return 1 ;; - ????????-????-????-????-????????????) return 0 ;; - esac - return 1 -} - -# Reads the nvx_layer format and EROFS identity for read_root_tokens. -# This init assembles only EROFS; it refuses GPT after root-token validation. -read_layer_token() { - layer_image=${1#distro,0xd0003000,} - layer_format=${layer_image%%:*} - case "$1" in - distro,0xd0003000,*,*) ;; - distro,0xd0003000,erofs) - layer_uuid= - return 0 - ;; - distro,0xd0003000,erofs:*) - layer_uuid=${layer_image#erofs:} - is_canonical_uuid "$layer_uuid" || - fatal "the nvx_layer kernel parameter names an invalid EROFS filesystem UUID: '$layer_uuid'" - return 0 - ;; - distro,0xd0003000,gpt | distro,0xd0003000,gpt:*) - return 0 - ;; - distro,0xd0003000,*) - # The form without a format names the UUID in its place. - if [ -z "$layer_format" ] || is_canonical_uuid "$layer_format"; then - fatal "the nvx_layer kernel parameter names no image format, erofs or gpt: '$1'" - fi - fatal "the nvx_layer kernel parameter names the unsupported image format '$layer_format'" - ;; - esac - fatal "the nvx_layer kernel parameter must be distro,0xd0003000,FORMAT[:IDENTITY], not '$1'" -} - -# Reads the boot tokens of the layered root, with the rules of the host's -# renderer, nvx::protocol::root: nvx_layer names the image of the distro block -# device, nvx_scratch=0xd0006000,ext4 or nvx_overlay_upper=ramfs the upper, and -# nvx_overlay_mode, nvx_scratch_journal, nvx_first_partition_bound, and -# nvx_ram_upper_percent the root's settings, each once. This init assembles -# only an EROFS image, mounts the overlay only for trusted lowers, and reads -# nothing of a scratch's superblock, so after those rules it refuses a GPT -# image, untrusted lowers, and a forbidden journal too. A bound is checked as -# an unsigned decimal even though it needs a GPT image, which this init cannot -# assemble. Sets layer_uuid to the filesystem UUID that the image must have, or to -# nothing if the token names none, upper to scratch or ram, and ram_percent to -# the RAM upper's cap, or to nothing for the kernel's default size. The parser -# also refuses a RAM upper beside a scratch block device, which this init -# checks against the kernel's devices instead, with refuse_scratch_device. The -# tests replay nvx-rs/tests/fixtures/root-tokens.json through it. -read_root_tokens() { - layer_seen=false - scratch_seen=false - ram_seen=false - layer_uuid= - first_root_parameter= - # The names of the settings that the command line names, and the values of - # three of them. - settings= - overlay_mode= - scratch_journal= - ram_percent= - # Kernel command-line tokens are deliberately split on spaces. - # shellcheck disable=SC2086 - set -f - for token in $cmdline; do - case "$token" in - nvx_layer | nvx_scratch | nvx_overlay_upper | nvx_overlay_mode | nvx_scratch_journal | nvx_first_partition_bound | nvx_ram_upper_percent) - fatal "the $token kernel parameter has no value" - ;; - nvx_layer=*) - [ "$layer_seen" = false ] || - fatal "the nvx_layer kernel parameter is repeated" - layer_seen=true - read_layer_token "${token#*=}" - ;; - nvx_scratch=*) - [ -n "$first_root_parameter" ] || first_root_parameter=nvx_scratch - [ "$scratch_seen" = false ] || - fatal "the nvx_scratch kernel parameter is repeated" - scratch_seen=true - [ "${token#*=}" = 0xd0006000,ext4 ] || - fatal "the nvx_scratch kernel parameter must be 0xd0006000,ext4, not '${token#*=}'" - ;; - nvx_overlay_upper=*) - [ -n "$first_root_parameter" ] || first_root_parameter=nvx_overlay_upper - [ "$ram_seen" = false ] || - fatal "the nvx_overlay_upper kernel parameter is repeated" - ram_seen=true - [ "${token#*=}" = ramfs ] || - fatal "the nvx_overlay_upper kernel parameter must be ramfs, not '${token#*=}'" - ;; - nvx_overlay_mode=* | nvx_scratch_journal=* | nvx_first_partition_bound=* | nvx_ram_upper_percent=*) - setting=${token%%=*} - [ -n "$first_root_parameter" ] || first_root_parameter=$setting - case " $settings " in *" $setting "*) fatal "the $setting kernel parameter is repeated" ;; esac - settings="$settings $setting" - case "$token" in - nvx_overlay_mode=trusted-lowers | nvx_overlay_mode=untrusted-lowers) overlay_mode=${token#*=} ;; - nvx_overlay_mode=*) fatal "the $setting kernel parameter must be trusted-lowers or untrusted-lowers, not '${token#*=}'" ;; - nvx_scratch_journal=allowed | nvx_scratch_journal=forbidden) scratch_journal=${token#*=} ;; - nvx_scratch_journal=*) fatal "the $setting kernel parameter must be allowed or forbidden, not '${token#*=}'" ;; - nvx_ram_upper_percent=[1-9] | nvx_ram_upper_percent=[1-8][0-9] | nvx_ram_upper_percent=90) ram_percent=${token#*=} ;; - nvx_ram_upper_percent=*) fatal "the $setting kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not '${token#*=}'" ;; - nvx_first_partition_bound= | nvx_first_partition_bound=0* | nvx_first_partition_bound=*[!0-9]*) fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" ;; - nvx_first_partition_bound=*) - bound_digits=${token#*=} - [ "${#bound_digits}" -le 20 ] || - fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" - if [ "${#bound_digits}" -eq 20 ]; then - bound_limit=18446744073709551615 - # Compare digits, never the full unsigned value. - while [ -n "$bound_digits" ]; do - bound_digit=${bound_digits%"${bound_digits#?}"} - limit_digit=${bound_limit%"${bound_limit#?}"} - [ "$bound_digit" -le "$limit_digit" ] || - fatal "the $setting kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '${token#*=}'" - [ "$bound_digit" -eq "$limit_digit" ] || break - bound_digits=${bound_digits#?} - bound_limit=${bound_limit#?} - done - fi - ;; - esac - ;; - esac - done - set +f - [ "$scratch_seen" = false ] || [ "$ram_seen" = false ] || - fatal "a layered root has one upper, but the command line names both nvx_scratch and nvx_overlay_upper" - [ "$layer_seen" = true ] || [ -z "$first_root_parameter" ] || - fatal "the $first_root_parameter kernel parameter needs an image, which nvx_layer names" - [ "$layer_seen" = true ] || - fatal "the command line names no distro layer: this init needs nvx_layer=distro,0xd0003000,erofs[:UUID]" - if [ "$scratch_seen" = true ]; then - upper=scratch - elif [ "$ram_seen" = true ]; then - upper=ram - else - fatal "the nvx_layer kernel parameter needs an upper, nvx_scratch or nvx_overlay_upper" - fi - [ -z "$scratch_journal" ] || [ "$upper" = scratch ] || - fatal "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch" - [ -z "$ram_percent" ] || [ "$upper" = ram ] || - fatal "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper" - if [ "$layer_format" != gpt ]; then - case " $settings " in *" nvx_first_partition_bound "*) fatal "the nvx_first_partition_bound kernel parameter needs a GPT image" ;; esac - fi - [ "$layer_format" != gpt ] || - fatal "nvx_layer=distro,0xd0003000,$layer_image names a GPT image, which this init cannot assemble: it assembles only an EROFS image" - [ "$overlay_mode" != untrusted-lowers ] || - fatal "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which this init cannot assemble: it mounts the overlay only for trusted lowers, with metacopy=on,xino=on" - [ "$scratch_journal" != forbidden ] || - fatal "nvx_scratch_journal=forbidden forbids the scratch a journal, which this init cannot check: it reads nothing of the scratch's superblock" -} - -# Fails if the kernel has a virtio-mmio device at the scratch block device's -# MMIO base, 0xd0006000, which a RAM upper must not have. nvx::protocol::root -# reads the devices from the virtio_mmio.device= parameters of the command -# line, with the kernel's rules; this init asks the kernel instead, which lists -# the window of each device that it added in /proc/iomem, where -# resolve_block_device finds them too. -refuse_scratch_device() { - scratch_address=$(printf '%d' 0xd0006000) - while IFS=' ' read -r range separator platform _; do - [ "$separator" = : ] || continue - case "$platform" in virtio-mmio.*) ;; *) continue ;; esac - start=$(printf '%d' "0x${range%%-*}") || continue - [ "$start" -ne "$scratch_address" ] || - fatal "a RAM upper takes no scratch block device, but the kernel has a virtio-mmio device at 0xd0006000" - done "$share_path") || - fatal "failed to create live share target $1" - fi - elif [ -e "$share_path" ]; then - [ -d "$share_path" ] || - fatal "live share target is not a directory: $1" - else - mkdir -m 0755 "$share_path" || - fatal "failed to create live share target $1" - fi - done -} - -# Records a mounted live share for teardown, most recent first. -record_live_share() { - share_mountpoints="$1${share_mountpoints:+ -$share_mountpoints}" -} - -# Checks that the live share target $1 is valid and that no other share hides -# or shadows it. -claim_share_target() { - validate_share_target "$1" - set -f - for mounted_target in $share_targets; do - case "$1/" in - "$mounted_target"/*) - set +f - fatal "live share targets overlap: $mounted_target and $1" - ;; - esac - case "$mounted_target/" in - "$1"/*) - set +f - fatal "live share targets overlap: $mounted_target and $1" - ;; - esac - done - set +f - share_targets="$share_targets $1" -} - -mount_live_share() { - share_tag=$1 - share_target=$2 - share_mode=$3 - case "$share_tag" in - '' | *[!A-Za-z0-9_-]*) fatal "live share tag is invalid" ;; - esac - case "$share_mode" in - ro | rw) ;; - *) fatal "live share mode is unsupported: $share_mode" ;; - esac - claim_share_target "$share_target" - make_share_mount_point "$share_target" - mount -t virtiofs -o "$share_mode,nosuid,nodev" "$share_tag" "$share_path" || - fatal "failed to mount live share $share_tag at $share_target" - record_live_share "$share_path" - echo "NVX-SANDBOX-SHARE: target=$share_target mode=$share_mode tag=$share_tag" -} - -# Splits the aggregate child specification $1, CHILD,TARGET,MODE[,file], into -# child_name, child_target, child_mode, and child_kind, which is file for a -# child that is a host file and directory otherwise. -parse_share_child() { - child_name=${1%%,*} - child_rest=${1#*,} - child_kind=directory - case "$child_rest" in - *,file) - child_kind="file" - child_rest=${child_rest%,file} - ;; - esac - child_target=${child_rest%,*} - child_mode=${child_rest##*,} - if [ "$child_rest" = "$1" ] || [ "$child_target" = "$child_rest" ]; then - fatal "live share child is invalid: $1" - fi - case "$child_name" in - '' | . | .. | *[!A-Za-z0-9._-]*) fatal "live share child is invalid: $1" ;; - esac - case "$child_mode" in - ro | rw) ;; - *) fatal "live share mode is unsupported: $child_mode" ;; - esac -} - -# Binds child $2 of the aggregate share mounted at $1 at the target $3 inside -# the container rootfs, with mode $4 and without set-user-ID programs or -# device nodes. The child is a directory, or a host file when $5 is file, and -# the kernel refuses to bind either at a mount point of the other kind. -bind_live_share() { - make_share_mount_point "$3" "$5" - mount -o bind "$1/$2" "$share_path" || - fatal "failed to bind live share $2 at $3" - record_live_share "$share_path" - mount -o "remount,bind,$4,nosuid,nodev" "$share_path" || - fatal "failed to restrict live share $2 at $3" - echo "NVX-SANDBOX-SHARE: target=$3 mode=$4 child=$2" -} - -mount_live_shares() { - # OpenVMM appends one virtfs_dir=, virtfs_tag=, virtfs_mode= triplet for - # its virtio-fs share, and virtfs_aggregate=1 when that share is an - # aggregate of host directories and files, which - # nvx_share=CHILD,TARGET,MODE[,file] tokens place. Parse every token - # before mounting anything, so that a malformed bootstrap mounts nothing. - share_dir= - share_tag= - share_mode= - share_fields=0 - share_aggregate=false - share_children= - # Kernel command-line tokens are deliberately split on spaces. - # shellcheck disable=SC2086 - set -f - for token in $cmdline; do - case "$token" in - virtfs_dir=?*) - share_dir=${token#*=} - share_fields=$((share_fields + 1)) - ;; - virtfs_tag=?*) - share_tag=${token#*=} - share_fields=$((share_fields + 1)) - ;; - virtfs_mode=?*) - share_mode=${token#*=} - share_fields=$((share_fields + 1)) - ;; - virtfs_dir=* | virtfs_tag=* | virtfs_mode=*) - set +f - fatal "live share bootstrap is incomplete" - ;; - virtfs_aggregate=1) share_aggregate=true ;; - nvx_share=*) share_children="$share_children ${token#*=}" ;; - esac - done - set +f - if [ "$share_fields" -eq 0 ] && [ "$share_aggregate" = false ] && - [ -z "$share_children" ]; then - return 0 - fi - [ "$share_fields" -eq 3 ] && [ -n "$share_dir" ] && [ -n "$share_tag" ] && - [ -n "$share_mode" ] || fatal "live share bootstrap is incomplete" - share_targets= - if [ "$share_aggregate" = false ]; then - [ -z "$share_children" ] || - fatal "live share children require an aggregate share" - mount_live_share "$share_tag" "$share_dir" "$share_mode" - return 0 - fi - # The aggregate's root lists every child, so it stays outside the - # container rootfs, where only this agent reaches it. - [ "$share_dir" = "$runtime/shares" ] || - fatal "the aggregate live share must be mounted at $runtime/shares" - case "$share_tag" in - '' | *[!A-Za-z0-9_-]*) fatal "live share tag is invalid" ;; - esac - case "$share_mode" in - ro | rw) ;; - *) fatal "live share mode is unsupported: $share_mode" ;; - esac - [ -n "$share_children" ] || fatal "the aggregate live share has no children" - share_seen= - set -f - for share_spec in $share_children; do - set +f - parse_share_child "$share_spec" - case " $share_seen " in - *" $child_name "*) fatal "live share child is not unique: $child_name" ;; - esac - share_seen="$share_seen $child_name" - claim_share_target "$child_target" - make_share_mount_point "$child_target" "$child_kind" - set -f - done - set +f - mkdir -p "$share_dir" || - fatal "failed to create the aggregate live share directory" - mount -t virtiofs -o "$share_mode,nosuid,nodev" "$share_tag" "$share_dir" || - fatal "failed to mount the aggregate live share $share_tag" - record_live_share "$share_dir" - set -f - for share_spec in $share_children; do - set +f - parse_share_child "$share_spec" - bind_live_share "$share_dir" "$child_name" "$child_target" "$child_mode" \ - "$child_kind" - set -f - done - set +f -} - -# Mounts the host's virtio-fs export of a managed sandbox, from which its -# guest agent maps host paths into the container rootfs as the mapping table -# that the host sends once the control session is ready orders them. OpenVMM -# appends one virtfs_dir=, virtfs_tag=, virtfs_mode= triplet and -# virtfs_aggregate=1 for the export, whose root lists one child per host -# directory or file. It goes at $hostfs/root, where only root can enter, so -# that a workload reaches host paths only through their mappings. The agent -# reads the nvx_maps= count itself, and nvx_share= tokens, which place the -# children of a one-shot sandbox, are refused. Every token is checked before -# anything is mounted. -mount_host_export() { - export_dir= - export_tag= - export_mode= - export_fields=0 - export_aggregate=false - # Kernel command-line tokens are deliberately split on spaces. - # shellcheck disable=SC2086 - set -f - for token in $cmdline; do - case "$token" in - virtfs_dir=?*) - export_dir=${token#*=} - export_fields=$((export_fields + 1)) - ;; - virtfs_tag=?*) - export_tag=${token#*=} - export_fields=$((export_fields + 1)) - ;; - virtfs_mode=?*) - export_mode=${token#*=} - export_fields=$((export_fields + 1)) - ;; - virtfs_dir=* | virtfs_tag=* | virtfs_mode=*) - set +f - fatal "host export bootstrap is incomplete" - ;; - virtfs_aggregate=1) export_aggregate=true ;; - nvx_share=*) - set +f - fatal "a managed sandbox maps its live shares from the host's mapping table, not from nvx_share= tokens" - ;; - esac - done - set +f - if [ "$export_fields" -eq 0 ] && [ "$export_aggregate" = false ]; then - return 0 - fi - [ "$export_fields" -eq 3 ] && [ "$export_aggregate" = true ] || - fatal "host export bootstrap is incomplete" - [ "$export_dir" = "$hostfs/root" ] || - fatal "the host export must be mounted at $hostfs/root" - case "$export_tag" in - '' | *[!A-Za-z0-9_-]*) fatal "host export tag is invalid" ;; - esac - case "$export_mode" in - ro | rw) ;; - *) fatal "host export mode is unsupported: $export_mode" ;; - esac - if ! (umask 077 && mkdir "$hostfs") || ! mkdir "$export_dir"; then - fatal "failed to create the host export directory" - fi - mount -t virtiofs -o "$export_mode,nosuid,nodev" "$export_tag" "$export_dir" || - fatal "failed to mount the host export $export_tag" - record_live_share "$export_dir" - echo "NVX-SANDBOX-EXPORT: mode=$export_mode tag=$export_tag" -} - -# Unmount in dependency order: the live shares are children of the overlay, as -# are the mappings that a managed sandbox's agent unmounts in its own mount -# namespace before it returns, and the overlay uses the layers and the upper. -teardown() { - status=$1 - rm -f "$runtime/container.pid" "$runtime/workload-machine-id" - unmount_live_shares || status=1 - if ! umount "$rootfs"; then - echo "NVX-SANDBOX-ERROR: failed to unmount the container overlay" >&2 - status=1 - fi - directory=$layers/distro - if mountpoint -q "$directory" && ! umount "$directory"; then - echo "NVX-SANDBOX-ERROR: failed to unmount the distro layer" >&2 - status=1 - fi - if ! umount "$upper_point"; then - echo "NVX-SANDBOX-ERROR: failed to unmount $upper_name" >&2 - status=1 - fi - echo "NVX-SANDBOX-EXIT: status=$status" - /sbin/nvx-exit "$status" - while :; do - sleep 3600 - done -} - -mkdir -p "$layers" "$rootfs" -# init mounts /run before the time ABI boot check, whose state lives there. -mountpoint -q /run || mount -t tmpfs -o mode=755,nosuid,nodev tmpfs /run || - fatal "failed to mount runtime tmpfs" -mkdir -p "$layers" "$rootfs" -configure_cgroups - -layer_capacity=$(cmdline_value nvx_layer_capacity_sectors || true) -read_root_tokens -# A RAM upper is refused beside a scratch block device before anything mounts. -[ "$upper" = scratch ] || refuse_scratch_device -mount_layer distro 0xd0003000 "$layer_uuid" "$layer_capacity" -layer_count=1 - -lowerdirs=$layers/distro - -mount_upper - -mount -t overlay overlay \ - -o "lowerdir=$lowerdirs,upperdir=$upper_point/upper,workdir=$upper_point/work,metacopy=on,xino=on" \ - "$rootfs" || fatal "failed to assemble the container overlay" - -entrypoint=$(cmdline_value nvx_entrypoint || true) -[ -n "$entrypoint" ] || entrypoint=/bin/sh -case "$entrypoint" in /*) ;; *) fatal "entrypoint must be an absolute path" ;; esac -hostname_value=$(cmdline_value nvx_hostname || true) -[ -n "$hostname_value" ] || hostname_value=nvx-sandbox -workload_uid=$(cmdline_value nvx_workload_uid || true) -workload_gid=$(cmdline_value nvx_workload_gid || true) -case "$workload_uid" in '' | 0 | *[!0-9]*) fatal "workload UID is unavailable" ;; esac -case "$workload_gid" in '' | 0 | *[!0-9]*) fatal "workload GID is unavailable" ;; esac -[ -f "$rootfs/etc/passwd" ] && [ ! -L "$rootfs/etc/passwd" ] || - fatal "workload user database is unavailable" -[ -f "$rootfs/etc/group" ] && [ ! -L "$rootfs/etc/group" ] || - fatal "workload group database is unavailable" -workload_record=$( - awk -F: -v uid="$workload_uid" ' - $3 == uid { count++; record = $1 ":" $3 ":" $4 ":" $6 } - END { if (count == 1) print record; else exit 1 } - ' "$rootfs/etc/passwd" -) || fatal "configured workload UID is unavailable" -workload_name=${workload_record%%:*} -workload_remainder=${workload_record#*:} -resolved_uid=${workload_remainder%%:*} -workload_remainder=${workload_remainder#*:} -resolved_gid=${workload_remainder%%:*} -workload_home=${workload_remainder#*:} -[ -n "$workload_name" ] && [ "$workload_home" != "$workload_remainder" ] || - fatal "configured workload user record is invalid" -[ "$resolved_uid" = "$workload_uid" ] && [ "$resolved_gid" = "$workload_gid" ] || - fatal "configured workload identity does not match the user database" -awk -F: -v gid="$workload_gid" '$3 == gid { found = 1 } END { exit !found }' \ - "$rootfs/etc/group" || fatal "configured workload GID is unavailable" -case "$workload_home" in /*) ;; *) fatal "configured workload home is invalid" ;; esac -[ -d "$rootfs$workload_home" ] && [ ! -L "$rootfs$workload_home" ] || - fatal "configured workload home is unavailable" - -workload_lifecycle=$(cmdline_value nvx_lifecycle || true) -[ -n "$workload_lifecycle" ] || workload_lifecycle=one-shot -case "$workload_lifecycle" in - one-shot) ;; - managed) - control_tty=$(cmdline_value nvx_control_tty || true) - [ "$control_tty" = hvc2 ] || - fatal "managed lifecycle requires the dedicated control tty" - ;; - *) fatal "unsupported workload lifecycle: $workload_lifecycle" ;; -esac - -# A one-shot sandbox binds its live shares now, as their boot tokens place -# them, and a managed sandbox's agent maps them from the host export once the -# host sends their mapping table. -case "$workload_lifecycle" in - managed) mount_host_export ;; - *) mount_live_shares ;; -esac - -if [ "$workload_lifecycle" = managed ]; then - # The managed agent enters each execution itself, as the first process of - # its own PID namespace, and generates the workload's machine ID. - give_back_launcher_process - echo "NVX-SANDBOX-READY: managed layers=$layer_count" - # The agent returns after a stop request, so managed sandboxes share the - # one-shot teardown instead of powering off with their mounts in place. - status=0 - /sbin/nvx-managed-agent \ - "/dev/$control_tty" "$rootfs" "$hostname_value" \ - "$workload_uid" "$workload_gid" "$workload_name" "$workload_home" || - status=$? - teardown "$status" -fi - -workload_machine_id=$(tr -d - "$runtime/workload-machine-id" - -set -- "$entrypoint" -# Kernel command-line tokens are deliberately split on spaces. -# shellcheck disable=SC2086 -set -f -for token in $cmdline; do - case "$token" in - nvx_arg=*) set -- "$@" "${token#*=}" ;; - esac -done -set +f - -barrier=$runtime/container-start -mkfifo "$barrier" -/sbin/nvx-container-launch \ - "$barrier" "$rootfs" "$hostname_value" \ - "$workload_uid" "$workload_gid" "$workload_name" "$workload_home" "$@" & -container_pid=$! -if ! echo "$container_pid" >/sys/fs/cgroup/container/cgroup.procs; then - kill "$container_pid" 2>/dev/null || true - wait "$container_pid" 2>/dev/null || true - fatal "failed to place the workload in its cgroup" -fi -printf '%s\n' "$container_pid" >"$runtime/container.pid" -printf 'start\n' >"$barrier" -rm -f "$barrier" - -echo "NVX-SANDBOX-READY: pid=$container_pid layers=$layer_count" -status=0 -wait "$container_pid" || status=$? -teardown "$status" diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 8f4e86efc..3b25c4e16 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -253,8 +253,7 @@ malformed, repeated, and conflicting tokens, a setting of an upper or a format that the layout lacks, and the earlier form without a format, with no fallback that probes the devices. It reads the virtio-mmio devices of the command line with `nvx::protocol::cmdline`, to refuse a RAM upper beside one at the scratch -base. `tests/fixtures/root-tokens.json` holds its cases, which the tests of the -guest's shell init replay too. +base. `tests/fixtures/root-tokens.json` holds its cases. ### `nvx::protocol::share` diff --git a/nvx-rs/src/guest/init.rs b/nvx-rs/src/guest/init.rs index 80ca4978f..c64dead84 100644 --- a/nvx-rs/src/guest/init.rs +++ b/nvx-rs/src/guest/init.rs @@ -35,7 +35,7 @@ //! status of `nvx::protocol::status` of the failure's class, such as 54, `ACTIVATION_ROOT`, for //! a root that does not assemble over its upper, or 51, `ACTIVATION_PROTOCOL`, for an activate //! request that changes the accepted one, which an `NVX-INIT-ACTIVATION:` line on the console -//! names first; or 125, as `nvx-init-agent`'s failures do, after another failure, which an +//! names first; or 125 after another failure, which an //! `NVX-INIT-ERROR:` line on the console names first. //! //! Outside PID 1, `nvx-init` changes nothing: it fails with status 2 and a message. @@ -68,7 +68,7 @@ pub(crate) use boot::{RestoreHandoff, Restored}; /// The process ID of the init process. const INIT_PROCESS: i32 = 1; /// The VM's exit status after a failure of PID 1 or the end of a helper, but for a failed restored -/// activation's: that of `nvx-init-agent`'s failures. +/// activation's. const FAILURE: u8 = 125; /// The out-of-memory score adjustment of PID 1 and its helpers, Linux's `OOM_SCORE_ADJ_MIN`, /// which exempts them from the out-of-memory killer. diff --git a/nvx-rs/src/guest/root.rs b/nvx-rs/src/guest/root.rs index 8c89b8630..5786aa577 100644 --- a/nvx-rs/src/guest/root.rs +++ b/nvx-rs/src/guest/root.rs @@ -228,8 +228,8 @@ const HOSTFS_DIRECTORY: &str = "hostfs"; /// The mount point of the host export, below the runtime directory: `root` in /// [`HOSTFS_DIRECTORY`], which exists once a root has mounted an export. const EXPORT_DIRECTORY: &str = "hostfs/root"; -/// The mode of the [`HOSTFS_DIRECTORY`] that a root creates, whatever the process's umask, as -/// NVX's shell init creates it under `umask 077`: only root may enter it. +/// The mode of the [`HOSTFS_DIRECTORY`] that a root creates, whatever the process's umask: only +/// root may enter it. const HOSTFS_DIRECTORY_MODE: u32 = 0o700; /// The mount point of a live share before it moves to its target, below the runtime directory /// and outside the root, which exists once a root has mounted a share. diff --git a/nvx-rs/src/protocol/export.rs b/nvx-rs/src/protocol/export.rs index eb93ef704..f78e9c231 100644 --- a/nvx-rs/src/protocol/export.rs +++ b/nvx-rs/src/protocol/export.rs @@ -18,10 +18,9 @@ //! single shared directory. //! //! Guests read them with [`HostExport::parse`], which accepts exactly these forms, all four tokens -//! or none, each once. It applies the checks with which NVX's shell init mounts the export of a -//! managed sandbox, and refuses `nvx_share=` tokens as that init does: NVX's one-shot lifecycle -//! binds the children of an aggregate as live shares with them, while a guest that mounts the -//! export maps host paths from its mapping table alone. +//! or none, each once, and refuses `nvx_share=` tokens: NVX's one-shot lifecycle binds the +//! children of an aggregate as live shares with them, while a guest that mounts the export maps +//! host paths from its mapping table alone. //! //! Like the rest of [`protocol`](super), it needs nothing outside the standard library, so the //! host, the guest, and test doubles share it. diff --git a/nvx-rs/src/protocol/root.rs b/nvx-rs/src/protocol/root.rs index a02f976b9..c0cd73ec4 100644 --- a/nvx-rs/src/protocol/root.rs +++ b/nvx-rs/src/protocol/root.rs @@ -33,10 +33,7 @@ //! scratch upper, the cap a RAM upper, and the bound a GPT disk. //! //! Guests read the tokens with [`RootLayout::parse`], which accepts exactly these forms and -//! refuses every other, with no fallback that probes the image or the devices. NVX's shell init -//! reads them with the same rules, and refuses the GPT image, the untrusted lowers, and a -//! forbidden journal, which it cannot assemble. It finds a scratch block device beside a RAM upper -//! among the devices that the kernel added, rather than on the command line. +//! refuses every other, with no fallback that probes the image or the devices. //! //! The tokens belong to the boot contract that //! [`BOOT_CONTRACT_VERSION`](crate::contract::BOOT_CONTRACT_VERSION) versions, so a change to diff --git a/nvx-rs/src/protocol/root/tests.rs b/nvx-rs/src/protocol/root/tests.rs index 241424766..dfa05a3f0 100644 --- a/nvx-rs/src/protocol/root/tests.rs +++ b/nvx-rs/src/protocol/root/tests.rs @@ -1,6 +1,5 @@ //! Tests of the boot tokens of a layered root. `tests/fixtures/root-tokens.json` holds the command -//! lines that every reader of the tokens must agree on, which the tests of the shell init replay -//! too. +//! lines that every reader of the tokens must agree on. use serde_json::Value; diff --git a/nvx-rs/tests/fixtures/root-tokens.json b/nvx-rs/tests/fixtures/root-tokens.json index 9d3e47e90..4bcbb1f40 100644 --- a/nvx-rs/tests/fixtures/root-tokens.json +++ b/nvx-rs/tests/fixtures/root-tokens.json @@ -1,14 +1,14 @@ { - "description": "Kernel command lines and the layout of a layered root that nvx::protocol::root parses from each, null for none, or the error with which it refuses the command line. The tests of nvx-rs replay them, and scripts/test_nvx_tools.py replays them through the shell init, guest/common/nvx-init-agent, which reads the same tokens: it accepts what the parser accepts of an EROFS image over either upper, for trusted lowers and without a forbidden journal, and refuses everything else, with shell_error in its message where a case names one. It reads the tokens of a RAM upper beside a scratch block device too, and refuses that device when it finds it in /proc/iomem, where the kernel lists the virtio-mmio devices that it added.", + "description": "Kernel command lines and the layout of a layered root that nvx::protocol::root parses from each, null for none, or the error with which it refuses the command line. The tests of nvx-rs replay them.", "cases": [ {"name": "an EROFS image with its UUID over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch"}}, {"name": "an EROFS image without an identity over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, - {"name": "a GPT image with its disk GUID over scratch", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_scratch=0xd0006000,ext4", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "scratch"}, "shell_error": "GPT image"}, - {"name": "a GPT image without an identity over scratch", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "layout": {"format": "gpt", "identity": null, "upper": "scratch"}, "shell_error": "GPT image"}, + {"name": "a GPT image with its disk GUID over scratch", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_scratch=0xd0006000,ext4", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "scratch"}}, + {"name": "a GPT image without an identity over scratch", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "layout": {"format": "gpt", "identity": null, "upper": "scratch"}}, {"name": "an EROFS image with its UUID in RAM", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_overlay_upper=ramfs", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram"}}, {"name": "an EROFS image without an identity in RAM", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, - {"name": "a GPT image with its disk GUID in RAM", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_overlay_upper=ramfs", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "ram"}, "shell_error": "GPT image"}, - {"name": "a GPT image without an identity in RAM", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_overlay_upper=ramfs", "layout": {"format": "gpt", "identity": null, "upper": "ram"}, "shell_error": "GPT image"}, + {"name": "a GPT image with its disk GUID in RAM", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_overlay_upper=ramfs", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "ram"}}, + {"name": "a GPT image without an identity in RAM", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_overlay_upper=ramfs", "layout": {"format": "gpt", "identity": null, "upper": "ram"}}, {"name": "the upper before the image", "command_line": "nvx_scratch=0xd0006000,ext4 nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch"}}, {"name": "the RAM upper before the image", "command_line": "nvx_overlay_upper=ramfs nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram"}}, {"name": "the tokens of a managed sandbox and of the VMM", "command_line": "quiet nvx_sandbox=1 nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4 nvx_entrypoint=/bin/sh nvx_hostname=nvx-sandbox nvx_arg=-c nvx_memory_max=268435456 nvx_pids_max=65 nvx_share=0-4f9cd4a1a4e2b5c3d6e7f8091a2b3c4d,/workspace,rw nr_cpus=1 nvx_workload_uid=65534 nvx_workload_gid=65534 nvx_lifecycle=managed virtio_mmio.device=0x1000@0xd0003000:19 virtio_mmio.device=0x1000@0xd0006000:22 virtio_mmio.device=0x1000@0xd0007000:23 nvx_control_tty=hvc2 virtfs_dir=/run/nvx/shares virtfs_tag=microvm virtfs_mode=rw virtfs_aggregate=1", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch"}}, @@ -18,13 +18,13 @@ {"name": "no boot tokens, as for a blockless root", "command_line": "quiet nvx_sandbox=1 nvx_entrypoint=/bin/sh", "layout": null}, {"name": "an empty command line", "command_line": "", "layout": null}, {"name": "a scratch device without boot tokens", "command_line": "quiet virtio_mmio.device=0x1000@0xd0006000:22", "layout": null}, - {"name": "the form without a format, which names the UUID alone", "command_line": "nvx_layer=distro,0xd0003000,11111111-1111-1111-1111-111111111111 nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names no image format, erofs or gpt: 'distro,0xd0003000,11111111-1111-1111-1111-111111111111'", "shell_error": "image format"}, - {"name": "an empty format", "command_line": "nvx_layer=distro,0xd0003000, nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names no image format, erofs or gpt: 'distro,0xd0003000,'", "shell_error": "image format"}, - {"name": "an identity without a format", "command_line": "nvx_layer=distro,0xd0003000,:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names no image format, erofs or gpt: 'distro,0xd0003000,:0123abcd-4567-89ef-0123-456789abcdef'", "shell_error": "image format"}, - {"name": "an unknown format", "command_line": "nvx_layer=distro,0xd0003000,squashfs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the unsupported image format 'squashfs'", "shell_error": "image format"}, - {"name": "a format in capitals", "command_line": "nvx_layer=distro,0xd0003000,EROFS:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the unsupported image format 'EROFS'", "shell_error": "image format"}, - {"name": "the dash that once skipped the UUID check", "command_line": "nvx_layer=distro,0xd0003000,- nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the unsupported image format '-'", "shell_error": "image format"}, - {"name": "a filesystem's format for the upper as the image's", "command_line": "nvx_layer=distro,0xd0003000,ext4 nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the unsupported image format 'ext4'", "shell_error": "image format"}, + {"name": "the form without a format, which names the UUID alone", "command_line": "nvx_layer=distro,0xd0003000,11111111-1111-1111-1111-111111111111 nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names no image format, erofs or gpt: 'distro,0xd0003000,11111111-1111-1111-1111-111111111111'"}, + {"name": "an empty format", "command_line": "nvx_layer=distro,0xd0003000, nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names no image format, erofs or gpt: 'distro,0xd0003000,'"}, + {"name": "an identity without a format", "command_line": "nvx_layer=distro,0xd0003000,:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names no image format, erofs or gpt: 'distro,0xd0003000,:0123abcd-4567-89ef-0123-456789abcdef'"}, + {"name": "an unknown format", "command_line": "nvx_layer=distro,0xd0003000,squashfs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the unsupported image format 'squashfs'"}, + {"name": "a format in capitals", "command_line": "nvx_layer=distro,0xd0003000,EROFS:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the unsupported image format 'EROFS'"}, + {"name": "the dash that once skipped the UUID check", "command_line": "nvx_layer=distro,0xd0003000,- nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the unsupported image format '-'"}, + {"name": "a filesystem's format for the upper as the image's", "command_line": "nvx_layer=distro,0xd0003000,ext4 nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the unsupported image format 'ext4'"}, {"name": "an empty identity", "command_line": "nvx_layer=distro,0xd0003000,erofs: nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid EROFS filesystem UUID: ''"}, {"name": "a UUID in capitals", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123ABCD-4567-89EF-0123-456789ABCDEF nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid EROFS filesystem UUID: '0123ABCD-4567-89EF-0123-456789ABCDEF'"}, {"name": "a UUID in braces", "command_line": "nvx_layer=distro,0xd0003000,erofs:{0123abcd-4567-89ef-0123-456789abcdef} nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid EROFS filesystem UUID: '{0123abcd-4567-89ef-0123-456789abcdef}'"}, @@ -34,8 +34,8 @@ {"name": "a UUID with a letter that is no digit", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdeg nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid EROFS filesystem UUID: '0123abcd-4567-89ef-0123-456789abcdeg'"}, {"name": "a UUID whose hyphen moved", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-456-789ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid EROFS filesystem UUID: '0123abcd-456-789ef-0123-456789abcdef'"}, {"name": "a UUID followed by another field", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef:ro nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid EROFS filesystem UUID: '0123abcd-4567-89ef-0123-456789abcdef:ro'"}, - {"name": "a GPT GUID in capitals", "command_line": "nvx_layer=distro,0xd0003000,gpt:FEDCBA98-7654-3210-FEDC-BA9876543210 nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid GPT disk GUID: 'FEDCBA98-7654-3210-FEDC-BA9876543210'", "shell_error": "GPT image"}, - {"name": "a GPT partition's GUID after the disk's", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid GPT disk GUID: 'fedcba98-7654-3210-fedc-ba9876543210:0123abcd-4567-89ef-0123-456789abcdef'", "shell_error": "GPT image"}, + {"name": "a GPT GUID in capitals", "command_line": "nvx_layer=distro,0xd0003000,gpt:FEDCBA98-7654-3210-FEDC-BA9876543210 nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid GPT disk GUID: 'FEDCBA98-7654-3210-FEDC-BA9876543210'"}, + {"name": "a GPT partition's GUID after the disk's", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names an invalid GPT disk GUID: 'fedcba98-7654-3210-fedc-ba9876543210:0123abcd-4567-89ef-0123-456789abcdef'"}, {"name": "a role other than distro", "command_line": "nvx_layer=runtime,0xd0004000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the block device 'runtime', but a layered root has only the distro one"}, {"name": "a role in capitals", "command_line": "nvx_layer=DISTRO,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter names the block device 'DISTRO', but a layered root has only the distro one"}, {"name": "the scratch block device's address", "command_line": "nvx_layer=distro,0xd0006000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_layer kernel parameter places the distro block device at '0xd0006000', not at 0xd0003000"}, @@ -81,14 +81,14 @@ {"name": "a RAM upper beside a scratch device token after --", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs -- virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "ram"}}, {"name": "a scratch upper beside its block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 virtio_mmio.device=0x1000@0xd0003000:19 virtio_mmio.device=0x1000@0xd0006000:22", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, {"name": "an EROFS image over scratch for trusted lowers, with a journal allowed", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "scratch", "overlay": "trusted-lowers", "journal": "allowed"}}, - {"name": "an EROFS image over scratch for untrusted lowers, without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "forbidden"}, "shell_error": "untrusted lowers"}, + {"name": "an EROFS image over scratch for untrusted lowers, without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "forbidden"}}, {"name": "an EROFS image in RAM for trusted lowers, with a cap", "command_line": "nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_overlay_upper=ramfs nvx_overlay_mode=trusted-lowers nvx_ram_upper_percent=25", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 25}}, - {"name": "an EROFS image in RAM for untrusted lowers, with the largest cap, its settings first", "command_line": "nvx_ram_upper_percent=90 nvx_overlay_mode=untrusted-lowers nvx_overlay_upper=ramfs nvx_layer=distro,0xd0003000,erofs", "layout": {"format": "erofs", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 90}, "shell_error": "untrusted lowers"}, - {"name": "a GPT image over scratch for trusted lowers, without a journal, with the smallest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted-lowers nvx_scratch_journal=forbidden nvx_first_partition_bound=1", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "scratch", "overlay": "trusted-lowers", "journal": "forbidden", "gpt_first_partition_bound": 1}, "shell_error": "GPT image"}, - {"name": "a GPT image over scratch for untrusted lowers, with a journal allowed and the largest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=allowed nvx_first_partition_bound=18446744073709551615", "layout": {"format": "gpt", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "allowed", "gpt_first_partition_bound": 18446744073709551615}, "shell_error": "GPT image"}, - {"name": "a GPT image in RAM for trusted lowers, with the smallest cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_overlay_upper=ramfs nvx_overlay_mode=trusted-lowers nvx_ram_upper_percent=1 nvx_first_partition_bound=4096", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 1, "gpt_first_partition_bound": 4096}, "shell_error": "GPT image"}, - {"name": "a GPT image in RAM for untrusted lowers, with a cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_overlay_upper=ramfs nvx_overlay_mode=untrusted-lowers nvx_ram_upper_percent=50 nvx_first_partition_bound=65536", "layout": {"format": "gpt", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 50, "gpt_first_partition_bound": 65536}, "shell_error": "GPT image"}, - {"name": "an EROFS image over scratch without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "journal": "forbidden"}, "shell_error": "forbids the scratch a journal"}, + {"name": "an EROFS image in RAM for untrusted lowers, with the largest cap, its settings first", "command_line": "nvx_ram_upper_percent=90 nvx_overlay_mode=untrusted-lowers nvx_overlay_upper=ramfs nvx_layer=distro,0xd0003000,erofs", "layout": {"format": "erofs", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 90}}, + {"name": "a GPT image over scratch for trusted lowers, without a journal, with the smallest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=trusted-lowers nvx_scratch_journal=forbidden nvx_first_partition_bound=1", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "scratch", "overlay": "trusted-lowers", "journal": "forbidden", "gpt_first_partition_bound": 1}}, + {"name": "a GPT image over scratch for untrusted lowers, with a journal allowed and the largest bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=allowed nvx_first_partition_bound=18446744073709551615", "layout": {"format": "gpt", "identity": null, "upper": "scratch", "overlay": "untrusted-lowers", "journal": "allowed", "gpt_first_partition_bound": 18446744073709551615}}, + {"name": "a GPT image in RAM for trusted lowers, with the smallest cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt:fedcba98-7654-3210-fedc-ba9876543210 nvx_overlay_upper=ramfs nvx_overlay_mode=trusted-lowers nvx_ram_upper_percent=1 nvx_first_partition_bound=4096", "layout": {"format": "gpt", "identity": "fedcba98-7654-3210-fedc-ba9876543210", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 1, "gpt_first_partition_bound": 4096}}, + {"name": "a GPT image in RAM for untrusted lowers, with a cap and a bound", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_overlay_upper=ramfs nvx_overlay_mode=untrusted-lowers nvx_ram_upper_percent=50 nvx_first_partition_bound=65536", "layout": {"format": "gpt", "identity": null, "upper": "ram", "overlay": "untrusted-lowers", "max_memory_percent": 50, "gpt_first_partition_bound": 65536}}, + {"name": "an EROFS image over scratch without a journal", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_scratch_journal=forbidden", "layout": {"format": "erofs", "identity": null, "upper": "scratch", "journal": "forbidden"}}, {"name": "the settings among the tokens of a managed sandbox", "command_line": "quiet nvx_ram_upper_percent=90 nvx_sandbox=1 nvx_overlay_mode=trusted-lowers nvx_layer=distro,0xd0003000,erofs:0123abcd-4567-89ef-0123-456789abcdef nvx_overlay_upper=ramfs nvx_entrypoint=/bin/sh", "layout": {"format": "erofs", "identity": "0123abcd-4567-89ef-0123-456789abcdef", "upper": "ram", "overlay": "trusted-lowers", "max_memory_percent": 90}}, {"name": "parameters whose names only begin or end like the settings' tokens", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_overlay_modes=x nvx_overlay_mode_x=1 nvx-overlay-mode=untrusted-lowers nvx_scratch_journals=forbidden nvx_first_partition_bounds=0 nvx_ram_upper=0 nvx_ram_upper_percents=0 xnvx_ram_upper_percent=0", "layout": {"format": "erofs", "identity": null, "upper": "scratch"}}, {"name": "a capped RAM upper with a scratch block device", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=40 virtio_mmio.device=0x1000@0xd0006000:22", "error": "a RAM upper takes no scratch block device, but the command line adds a virtio-mmio device at 0xd0006000"}, @@ -109,15 +109,15 @@ {"name": "a cap with an empty value", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_ram_upper_percent=", "error": "the nvx_ram_upper_percent kernel parameter must be a decimal number from 1 to 90, without a sign or leading zeros, not ''"}, {"name": "a bound of 0, before its image", "command_line": "nvx_first_partition_bound=0 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0'"}, {"name": "a hexadecimal bound, before its image", "command_line": "nvx_first_partition_bound=0x1000 nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '0x1000'"}, - {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, - {"name": "a bound past 64 bits before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551616 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, - {"name": "a 21 digit bound before an EROFS image", "command_line": "nvx_first_partition_bound=184467440737095516150 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'", "shell_error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'"}, - {"name": "the largest bound before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551615 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, - {"name": "a 20 digit bound below the largest before an EROFS image", "command_line": "nvx_first_partition_bound=17999999999999999999 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image", "shell_error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "a bound past 64 bits", "command_line": "nvx_layer=distro,0xd0003000,gpt nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=18446744073709551616", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a bound past 64 bits before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551616 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '18446744073709551616'"}, + {"name": "a 21 digit bound before an EROFS image", "command_line": "nvx_first_partition_bound=184467440737095516150 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter must be a decimal number from 1 to 18446744073709551615, without a sign or leading zeros, not '184467440737095516150'"}, + {"name": "the largest bound before an EROFS image", "command_line": "nvx_first_partition_bound=18446744073709551615 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, + {"name": "a 20 digit bound below the largest before an EROFS image", "command_line": "nvx_first_partition_bound=17999999999999999999 nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, {"name": "a journal policy in RAM", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs nvx_scratch_journal=allowed", "error": "the nvx_scratch_journal kernel parameter needs a scratch upper, nvx_scratch"}, {"name": "a cap over scratch", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_ram_upper_percent=50", "error": "the nvx_ram_upper_percent kernel parameter needs a RAM upper, nvx_overlay_upper"}, {"name": "a bound of an EROFS image", "command_line": "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 nvx_first_partition_bound=4096", "error": "the nvx_first_partition_bound kernel parameter needs a GPT image"}, - {"name": "settings without a root", "command_line": "quiet nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "error": "the nvx_overlay_mode kernel parameter needs an image, which nvx_layer names", "shell_error": "the nvx_overlay_mode kernel parameter needs an image, which nvx_layer names"}, - {"name": "a cap before a RAM upper without an image", "command_line": "nvx_ram_upper_percent=50 nvx_overlay_upper=ramfs", "error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names", "shell_error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names"} + {"name": "settings without a root", "command_line": "quiet nvx_overlay_mode=trusted-lowers nvx_scratch_journal=allowed", "error": "the nvx_overlay_mode kernel parameter needs an image, which nvx_layer names"}, + {"name": "a cap before a RAM upper without an image", "command_line": "nvx_ram_upper_percent=50 nvx_overlay_upper=ramfs", "error": "the nvx_ram_upper_percent kernel parameter needs an image, which nvx_layer names"} ] } diff --git a/scripts/nvx_tools/build.py b/scripts/nvx_tools/build.py index b7b97734f..3d320b631 100644 --- a/scripts/nvx_tools/build.py +++ b/scripts/nvx_tools/build.py @@ -796,11 +796,6 @@ def _install_guest_files( alpine = BuildConstants.REPO_ROOT / AlpineBuildConstants.GUEST_DIRECTORY scripts.extend( [ - ( - "nvx-init-agent", - common / "nvx-init-agent", - root / "sbin" / "nvx-init-agent", - ), ( "nvx-container-enter", alpine / "nvx-container-enter", diff --git a/scripts/test_microvm_tests.py b/scripts/test_microvm_tests.py index cd4af4032..ef0df03ac 100644 --- a/scripts/test_microvm_tests.py +++ b/scripts/test_microvm_tests.py @@ -17131,23 +17131,6 @@ def require(path: Path, description: str) -> Path: guest_boot.assert_not_called() lifecycle.assert_not_called() - def test_managed_executions_enter_through_the_agent_not_the_container_helpers(self): - root = Path(__file__).resolve().parent.parent - bootstrap = (root / "guest" / "common" / "nvx-init-agent").read_text() - launcher = (root / "guest" / "alpine" / "nvx-container-enter").read_text() - # The managed agent generates the workload's machine ID itself, so the - # init agent generates one only for a one-shot workload. - self.assertLess( - bootstrap.index(" /sbin/nvx-managed-agent \\"), - bootstrap.index('>"$runtime/workload-machine-id"'), - ) - self.assertLess( - bootstrap.index('>"$runtime/workload-machine-id"'), - bootstrap.index("/sbin/nvx-container-launch"), - ) - self.assertNotIn("NVX_EXEC_CONFIG_FD", launcher) - self.assertNotIn("nvx-managed-agent", launcher) - def test_runner_uses_ubuntu_artifact_and_default_memory(self): requested: list[Path] = [] diff --git a/scripts/test_nvx_tools.py b/scripts/test_nvx_tools.py index 97a5aee56..e4d41ff37 100644 --- a/scripts/test_nvx_tools.py +++ b/scripts/test_nvx_tools.py @@ -17223,13 +17223,6 @@ def test_azurelinux_initramfs_uses_locked_busybox_applets(self): # util-linux provides these; the BusyBox applets lack required options. self.assertNotIn("setpriv", applets) self.assertNotIn("unshare", applets) - launcher = ( - BuildConstants.REPO_ROOT - / "guest" - / "common" - / "nvx-container-launch-azurelinux" - ).read_text(encoding="utf-8") - self.assertIn("exec unshare --mount --pid --uts --fork --kill-child", launcher) self.assertIn( "busybox", [package["name"] for package in azurelinux.load_package_lock()] ) @@ -18081,7 +18074,6 @@ def setUp(self): for name, script in { "nvx-time": f"printf '4242\\n' >{shlex.quote(self.pid.as_posix())}\n", "nvx-init": 'printf "new-init"; printf " <%s>" "$@"; echo\n', - "nvx-init-agent": 'printf "old-init"; printf " <%s>" "$@"; echo\n', "nvx-ingress": ( f'printf "%s\\n" "$$" >{shlex.quote(self.announcer.as_posix())}\n' ), @@ -18098,7 +18090,6 @@ def _run(self, cmdline: str) -> subprocess.CompletedProcess[str]: command_line.write_text(cmdline, encoding="utf-8") source = self.INIT.read_text(encoding="utf-8") replacements = { - "/sbin/nvx-init-agent": self.helpers["nvx-init-agent"], "/sbin/nvx-init": self.helpers["nvx-init"], "/sbin/nvx-time": self.helpers["nvx-time"], "/sbin/nvx-ingress": self.helpers["nvx-ingress"], @@ -18207,7 +18198,6 @@ def test_blockless_platform_and_snapshot_vms_keep_their_routes(self): self.assertIn("hostmount\n", result.stdout) self.assertIn("blockless-shell\n", result.stdout) self.assertNotIn("new-init", result.stdout) - self.assertNotIn("old-init", result.stdout) if tokens == "shellsnap snap": self.assertEqual(result.stdout.count("blockless-snapshot\n"), 2) self.assertIn("SNAP-RESTORE-OK\n", result.stdout) @@ -18250,11 +18240,9 @@ def test_missing_helpers_or_selected_init_fail_without_fallback(self): result = self._run("nvx_sandbox=1 nvx_snapshot_tier=platform") self.assertEqual(result.returncode, 125, result.stdout) self.assertIn("selected guest cannot act as sandbox control", result.stderr) - self.assertNotIn("old-init", result.stdout) result = self._run("nvx_sandbox=1 nvx_lifecycle=managed") self.assertEqual(result.returncode, 125, result.stdout) self.assertIn("selected guest cannot act as sandbox control", result.stderr) - self.assertNotIn("old-init", result.stdout) class GuestInitWorkloadIdentityTests(unittest.TestCase): @@ -18388,518 +18376,17 @@ def _aggregate_child_name(index: int, target: str) -> str: return f"{index}-{hashlib.sha256(target.encode()).hexdigest()[:32]}" -class SandboxShareAgentTests(unittest.TestCase): - AGENT = Path(__file__).parents[1] / "guest" / "common" / "nvx-init-agent" +class HostmountShareTests(unittest.TestCase): + HOSTMOUNT = Path(__file__).parents[1] / "guest" / "common" / "nvx-hostmount" def setUp(self): shell = _posix_shell() if shell is None: self.skipTest("POSIX shell is unavailable") self.shell = shell - self.source = self.AGENT.read_text(encoding="utf-8") - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - self.rootfs = self.root / "rootfs" - self.rootfs.mkdir() - self.runtime = (self.root / "run").as_posix() - - SHARE_FUNCTIONS = ( - "validate_share_target", - "make_share_mount_point", - "record_live_share", - "claim_share_target", - "mount_live_share", - "parse_share_child", - "bind_live_share", - "mount_live_shares", - "mount_host_export", - ) - - def _aggregate(self, *children: str, mode: str = "rw") -> str: - """Returns the bootstrap of an aggregate whose children the - CHILD,TARGET,MODE specifications `children` place.""" - tokens = [ - f"virtfs_dir={self.runtime}/shares", - "virtfs_tag=microvm", - f"virtfs_mode={mode}", - "virtfs_aggregate=1", - *(f"nvx_share={child}" for child in children), - ] - return " ".join(tokens) - - def _export( - self, *extra: str, directory: str | None = None, mode: str = "rw" - ) -> str: - """Returns the bootstrap of a managed sandbox's host export at - `directory`, by default its place, followed by the tokens `extra`.""" - tokens = [ - f"virtfs_dir={directory or self.runtime + '/hostfs/root'}", - "virtfs_tag=microvm", - f"virtfs_mode={mode}", - "virtfs_aggregate=1", - *extra, - ] - return " ".join(tokens) - - def _run( - self, cmdline: str, function: str = "mount_live_shares" - ) -> tuple[subprocess.CompletedProcess[str], str]: - mount_log = self.root / "mount.log" - mount_log.unlink(missing_ok=True) - functions = "".join( - _shell_function(self.source, name) for name in self.SHARE_FUNCTIONS - ) - script = ( - "set -eu\n" - "rootfs=$1\ncmdline=$2\nmount_log=$3\nruntime=$4\nshare_mountpoints=\n" - "hostfs=$runtime/hostfs\n" - 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' - 'mount() { printf "%s\\n" "$*" >>"$mount_log"; }\n' - f"{functions}" - f"{function}\n" - "echo \"mountpoints=$(printf '%s' \"$share_mountpoints\" | tr '\\n' ' ')\"\n" - ) - result = subprocess.run( - [ - self.shell, - "-s", - "--", - self.rootfs.as_posix(), - cmdline, - mount_log.as_posix(), - self.runtime, - ], - input=script, - text=True, - capture_output=True, - check=False, - ) - log = mount_log.read_text(encoding="utf-8") if mount_log.exists() else "" - return result, log - - def _run_teardown( - self, - script: str, - *, - shares: tuple[str, ...], - failing: str = "", - upper: str = "scratch", - ) -> tuple[subprocess.CompletedProcess[str], list[str]]: - runtime = self.root / "run" - runtime.mkdir(exist_ok=True) - for name in ("container.pid", "workload-machine-id"): - (runtime / name).write_text("x\n", encoding="utf-8") - log = self.root / "teardown.log" - log.unlink(missing_ok=True) - functions = "".join( - _shell_function(self.source, name) - for name in ("unmount_live_shares", "fatal", "teardown") - ).replace("/sbin/nvx-exit", "nvx_exit") - # The agent records mounted shares one per line, most recent first. - # Windows drops a newline from a process argument, so pass a separator. - mountpoints = "|".join( - f"{self.rootfs.as_posix()}{target}" for target in reversed(shares) - ) - # mount_upper sets the upper's mount point and name. - upper_point, upper_name = { - "scratch": ("$runtime/scratch", "scratch"), - "ram": ("$runtime/tmpfs", "the tmpfs upper"), - }[upper] - result = subprocess.run( - [ - self.shell, - "-s", - "--", - runtime.as_posix(), - log.as_posix(), - mountpoints, - failing, - ], - input=( - "set -eu\n" - "runtime=$1\nlog=$2\nfailing=$4\n" - "share_mountpoints=$(printf '%s' \"$3\" | tr '|' '\\n')\n" - "rootfs=$runtime/rootfs\nlayers=$runtime/layers\n" - f'upper_point={upper_point}\nupper_name="{upper_name}"\n' - 'umount() { printf "umount %s\\n" "$1" >>"$log"; ' - '[ "$1" != "$failing" ]; }\n' - 'mountpoint() { [ "$2" = "$layers/distro" ]; }\n' - 'nvx_exit() { printf "exit %s\\n" "$1" >>"$log"; exit "$1"; }\n' - f"{functions}{script}\n" - ), - text=True, - capture_output=True, - check=False, - ) - lines = log.read_text(encoding="utf-8").splitlines() if log.exists() else [] - return result, lines - - def test_agent_mounts_share_after_lifecycle_checks_for_both_lifecycles(self): - # A one-shot sandbox binds its shares from their tokens, and a managed - # sandbox mounts only the export from which its agent maps them. - mount_call = self.source.index( - '\ncase "$workload_lifecycle" in\n' - " managed) mount_host_export ;;\n" - " *) mount_live_shares ;;\n" - "esac\n" - ) - self.assertEqual(self.source.count("\n *) mount_live_shares ;;\n"), 1) - self.assertEqual(self.source.count("mount_host_export ;;"), 1) - self.assertLess( - self.source.index('fatal "configured workload home is unavailable"'), - mount_call, - ) - self.assertLess( - self.source.index('fatal "unsupported workload lifecycle'), mount_call - ) - machine_id = self.source.index('>"$runtime/workload-machine-id"') - managed_agent = self.source.index("\n /sbin/nvx-managed-agent \\\n") - self.assertLess(mount_call, managed_agent) - # The managed agent generates its own machine ID; one-shot workloads - # get theirs from the init agent. - self.assertLess(managed_agent, machine_id) - self.assertLess(machine_id, self.source.index("/sbin/nvx-container-launch")) - self.assertNotIn("exec /sbin/nvx-managed-agent", self.source) - self.assertEqual(self.source.count('\nteardown "$status"\n'), 1) - self.assertEqual(self.source.count('\n teardown "$status"\n'), 1) - self.assertLess(managed_agent, self.source.index('\n teardown "$status"\n')) - self.assertLess( - self.source.index("\n give_back_launcher_process\n"), managed_agent - ) - - def test_managed_lifecycle_gives_back_the_launcher_process(self): - limit = self.root / "pids.max" - script = ( - 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' - + _shell_function(self.source, "give_back_launcher_process") - ).replace("/sys/fs/cgroup/container/pids.max", limit.as_posix()) - for pids_max, expected in ( - ("65", "64\n"), - ("2", "1\n"), - ("", None), - ("max", None), - ): - with self.subTest(pids_max=pids_max): - limit.unlink(missing_ok=True) - result = subprocess.run( - [self.shell, "-s", "--", pids_max], - input=f"{script}pids_max=$1\ngive_back_launcher_process\n", - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - if expected is None: - self.assertFalse(limit.exists()) - else: - self.assertEqual(limit.read_text(encoding="utf-8"), expected) - - def test_agent_teardown_unmounts_share_before_overlay_layers_and_scratch(self): - result, log = self._run_teardown("teardown 7", shares=("/workspace",)) - runtime = (self.root / "run").as_posix() - self.assertEqual(result.returncode, 7, result.stderr) - self.assertEqual( - log, - [ - f"umount {self.rootfs.as_posix()}/workspace", - f"umount {runtime}/rootfs", - f"umount {runtime}/layers/distro", - f"umount {runtime}/scratch", - "exit 7", - ], - ) - self.assertIn("NVX-SANDBOX-EXIT: status=7", result.stdout) - self.assertFalse((self.root / "run" / "workload-machine-id").exists()) - self.assertFalse((self.root / "run" / "container.pid").exists()) - - result, log = self._run_teardown("teardown 0", shares=()) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(log[0], f"umount {runtime}/rootfs") - - def test_agent_teardown_unmounts_the_tmpfs_upper_last(self): - runtime = (self.root / "run").as_posix() - result, log = self._run_teardown( - "teardown 0", shares=("/workspace",), upper="ram" - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - log, - [ - f"umount {self.rootfs.as_posix()}/workspace", - f"umount {runtime}/rootfs", - f"umount {runtime}/layers/distro", - f"umount {runtime}/tmpfs", - "exit 0", - ], - ) - - for upper, point, name in ( - ("scratch", "scratch", "scratch"), - ("ram", "tmpfs", "the tmpfs upper"), - ): - with self.subTest(upper=upper): - result, log = self._run_teardown( - "teardown 0", - shares=(), - failing=f"{runtime}/{point}", - upper=upper, - ) - self.assertEqual(result.returncode, 1, result.stdout) - self.assertEqual( - result.stderr, - f"NVX-SANDBOX-ERROR: failed to unmount {name}\n", - ) - self.assertEqual(log[-2:], [f"umount {runtime}/{point}", "exit 1"]) - self.assertIn("NVX-SANDBOX-EXIT: status=1", result.stdout) - - def test_agent_teardown_unmounts_shares_in_reverse_mount_order(self): - rootfs = self.rootfs.as_posix() - runtime = (self.root / "run").as_posix() - result, log = self._run_teardown( - "teardown 0", shares=("/workspace", "/opt/hostedtoolcache") - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - log[:3], - [ - f"umount {rootfs}/opt/hostedtoolcache", - f"umount {rootfs}/workspace", - f"umount {runtime}/rootfs", - ], - ) - - # A share that fails to unmount does not stop the others. - result, log = self._run_teardown( - "teardown 0", - shares=("/workspace", "/opt/hostedtoolcache"), - failing=f"{rootfs}/opt/hostedtoolcache", - ) - self.assertEqual(result.returncode, 1, result.stdout) - self.assertIn("failed to unmount the live share", result.stderr) - self.assertEqual( - log[:2], - [f"umount {rootfs}/opt/hostedtoolcache", f"umount {rootfs}/workspace"], - ) - self.assertEqual(log[-1], "exit 1") - - def test_agent_teardown_reports_share_unmount_failure(self): - share = f"{self.rootfs.as_posix()}/workspace" - result, log = self._run_teardown( - "teardown 0", shares=("/workspace",), failing=share - ) - self.assertEqual(result.returncode, 1, result.stdout) - self.assertIn("failed to unmount the live share", result.stderr) - self.assertEqual(log[0], f"umount {share}") - self.assertEqual(log[-1], "exit 1") - self.assertIn("NVX-SANDBOX-EXIT: status=1", result.stdout) - - def test_agent_fatal_unmounts_share_before_power_off(self): - result, log = self._run_teardown( - 'fatal "synthetic failure"', shares=("/workspace",) - ) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn("NVX-SANDBOX-ERROR: synthetic failure", result.stderr) - self.assertEqual( - log, [f"umount {self.rootfs.as_posix()}/workspace", "exit 125"] - ) - - result, log = self._run_teardown('fatal "early failure"', shares=()) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertEqual(log, ["exit 125"]) - - rootfs = self.rootfs.as_posix() - result, log = self._run_teardown( - 'fatal "late failure"', shares=("/workspace", "/opt/hostedtoolcache") - ) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertEqual( - log, - [ - f"umount {rootfs}/opt/hostedtoolcache", - f"umount {rootfs}/workspace", - "exit 125", - ], - ) - - def test_agent_skips_share_without_bootstrap_tokens(self): - result, log = self._run("console=hvc0 nvx_sandbox=1") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("mountpoints=\n", result.stdout) - self.assertEqual(log, "") - - def test_agent_mounts_share_inside_container_rootfs(self): - (self.rootfs / "opt").mkdir() - for mode in ("rw", "ro"): - with self.subTest(mode=mode): - result, log = self._run( - "nvx_sandbox=1 virtfs_dir=/opt/hostedtoolcache " - f"virtfs_tag=microvm virtfs_mode={mode}" - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertTrue((self.rootfs / "opt" / "hostedtoolcache").is_dir()) - self.assertIn( - f"NVX-SANDBOX-SHARE: target=/opt/hostedtoolcache mode={mode}", - result.stdout, - ) - self.assertEqual( - log.split(), - [ - "-t", - "virtiofs", - "-o", - f"{mode},nosuid,nodev", - "microvm", - f"{self.rootfs.as_posix()}/opt/hostedtoolcache", - ], - ) - self.assertIn( - f"mountpoints={self.rootfs.as_posix()}/opt/hostedtoolcache\n", - result.stdout, - ) - - def test_managed_agent_mounts_no_export_without_bootstrap_tokens(self): - result, log = self._run("console=hvc0 nvx_sandbox=1", "mount_host_export") - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn("mountpoints=\n", result.stdout) - self.assertEqual(log, "") - self.assertFalse((self.root / "run" / "hostfs").exists()) - - def test_managed_agent_mounts_the_export_where_only_root_can_enter(self): - runtime = self.root / "run" - runtime.mkdir() - hostfs = runtime / "hostfs" - for mode in ("rw", "ro"): - with self.subTest(mode=mode): - shutil.rmtree(hostfs, ignore_errors=True) - result, log = self._run( - self._export("nvx_maps=2", mode=mode), "mount_host_export" - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - log.splitlines(), - [ - f"-t virtiofs -o {mode},nosuid,nodev microvm " - f"{self.runtime}/hostfs/root" - ], - ) - self.assertIn( - f"NVX-SANDBOX-EXPORT: mode={mode} tag=microvm\n", result.stdout - ) - # Teardown unmounts the export like a share. - self.assertIn( - f"mountpoints={self.runtime}/hostfs/root\n", result.stdout - ) - self.assertTrue((hostfs / "root").is_dir()) - if os.name == "posix": - self.assertEqual(hostfs.stat().st_mode & 0o777, 0o700) - # The agent maps into the container rootfs; the shell binds nothing. - self.assertEqual(list(self.rootfs.iterdir()), []) - - def test_managed_agent_refuses_share_tokens_and_other_exports(self): - (self.root / "run").mkdir() - export = f"{self.runtime}/hostfs/root" - for cmdline, message in ( - ( - self._export("nvx_share=0,/workspace,rw"), - "not from nvx_share= tokens", - ), - ("nvx_share=0,/workspace,rw", "not from nvx_share= tokens"), - ( - self._export(directory=f"{self.runtime}/shares"), - f"the host export must be mounted at {export}", - ), - (self._export(directory="/workspace"), "must be mounted at"), - ( - self._export().replace(" virtfs_aggregate=1", ""), - "host export bootstrap is incomplete", - ), - ("virtfs_aggregate=1", "host export bootstrap is incomplete"), - (self._export(f"virtfs_dir={export}"), "bootstrap is incomplete"), - (self._export("virtfs_tag="), "bootstrap is incomplete"), - ( - self._export().replace("virtfs_tag=microvm", "virtfs_tag=a.b"), - "host export tag is invalid", - ), - (self._export(mode="rx"), "host export mode is unsupported: rx"), - ): - with self.subTest(cmdline=cmdline): - result, log = self._run(cmdline, "mount_host_export") - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn(message, result.stderr) - # Every token is checked before anything is created or mounted. - self.assertEqual(log, "") - self.assertFalse((self.root / "run" / "hostfs").exists()) - - def test_managed_agent_refuses_an_export_directory_that_exists(self): - (self.root / "run" / "hostfs").mkdir(parents=True) - result, log = self._run(self._export(), "mount_host_export") - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn("failed to create the host export directory", result.stderr) - self.assertEqual(log, "") - - def test_agent_binds_every_child_of_an_aggregate(self): - rootfs = self.rootfs.as_posix() - result, log = self._run( - "nvx_sandbox=1 " - + self._aggregate("0,/workspace,rw", "1,/opt/hostedtoolcache,ro") - ) - self.assertEqual(result.returncode, 0, result.stderr) - # The aggregate stays outside the container rootfs; each child is bound - # inside it with its own mode. - self.assertEqual( - log.splitlines(), - [ - f"-t virtiofs -o rw,nosuid,nodev microvm {self.runtime}/shares", - f"-o bind {self.runtime}/shares/0 {rootfs}/workspace", - f"-o remount,bind,rw,nosuid,nodev {rootfs}/workspace", - f"-o bind {self.runtime}/shares/1 {rootfs}/opt/hostedtoolcache", - f"-o remount,bind,ro,nosuid,nodev {rootfs}/opt/hostedtoolcache", - ], - ) - self.assertIn( - "NVX-SANDBOX-SHARE: target=/workspace mode=rw child=0\n" - "NVX-SANDBOX-SHARE: target=/opt/hostedtoolcache mode=ro child=1\n", - result.stdout, - ) - # Teardown unmounts the most recent bind first, and the aggregate last. - self.assertIn( - f"mountpoints={rootfs}/opt/hostedtoolcache {rootfs}/workspace " - f"{self.runtime}/shares\n", - result.stdout, - ) - - def test_agent_binds_children_by_the_names_that_nvx_gives_them(self): - rootfs = self.rootfs.as_posix() - workspace = _aggregate_child_name(0, "/workspace") - tools = _aggregate_child_name(1, "/opt/tools") - result, log = self._run( - self._aggregate( - f"{workspace},/workspace,rw", f"{tools},/opt/tools,ro", "a.b_C-9,/x,ro" - ) - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertIn( - f"-o bind {self.runtime}/shares/{workspace} {rootfs}/workspace\n", log - ) - self.assertIn( - f"-o bind {self.runtime}/shares/{tools} {rootfs}/opt/tools\n", log - ) - self.assertIn(f"-o bind {self.runtime}/shares/a.b_C-9 {rootfs}/x\n", log) - self.assertIn( - f"NVX-SANDBOX-SHARE: target=/workspace mode=rw child={workspace}\n", - result.stdout, - ) - # A repeated name is refused, however the child is named. - result, log = self._run( - self._aggregate(f"{workspace},/workspace,rw", f"{workspace},/b,ro") - ) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn("child is not unique", result.stderr) - self.assertEqual(log, "") def test_hostmount_accepts_only_child_names_inside_the_aggregate(self): - hostmount = self.AGENT.with_name("nvx-hostmount").read_text(encoding="utf-8") + hostmount = self.HOSTMOUNT.read_text(encoding="utf-8") script = ( 'fail() { echo "FAIL: $*" >&2; exit 1; }\n' f"{_shell_function(hostmount, 'parse_share')}" @@ -18946,7 +18433,7 @@ def parse(share: str) -> subprocess.CompletedProcess[str]: self.assertIn("invalid live share", result.stderr) def test_hostmount_makes_file_mount_points_without_following_links(self): - hostmount = self.AGENT.with_name("nvx-hostmount").read_text(encoding="utf-8") + hostmount = self.HOSTMOUNT.read_text(encoding="utf-8") script = ( f"{_shell_function(hostmount, 'make_file_mount_point')}" 'make_file_mount_point "$1"\n' @@ -18987,626 +18474,6 @@ def make(target: Path) -> int: self.assertEqual(make(linked), 1) self.assertEqual(existing.read_bytes(), b"{}") - def test_agent_binds_a_file_child_at_a_file(self): - rootfs = self.rootfs.as_posix() - (self.rootfs / "opt").mkdir() - (self.rootfs / "opt" / "existing.json").write_text("{}", encoding="utf-8") - result, log = self._run( - "nvx_sandbox=1 " - + self._aggregate( - "0,/workspace,rw", - "1,/etc/app/settings.json,ro,file", - "2,/opt/existing.json,rw,file", - ) - ) - self.assertEqual(result.returncode, 0, result.stderr) - # Each file child gets a file mount point, created inside the container - # rootfs or reused there, and is bound with its own mode. - settings = self.rootfs / "etc" / "app" / "settings.json" - self.assertTrue(settings.is_file()) - self.assertEqual(settings.read_text(encoding="utf-8"), "") - self.assertEqual( - (self.rootfs / "opt" / "existing.json").read_text(encoding="utf-8"), "{}" - ) - self.assertTrue((self.rootfs / "workspace").is_dir()) - self.assertEqual( - log.splitlines(), - [ - f"-t virtiofs -o rw,nosuid,nodev microvm {self.runtime}/shares", - f"-o bind {self.runtime}/shares/0 {rootfs}/workspace", - f"-o remount,bind,rw,nosuid,nodev {rootfs}/workspace", - f"-o bind {self.runtime}/shares/1 {rootfs}/etc/app/settings.json", - f"-o remount,bind,ro,nosuid,nodev {rootfs}/etc/app/settings.json", - f"-o bind {self.runtime}/shares/2 {rootfs}/opt/existing.json", - f"-o remount,bind,rw,nosuid,nodev {rootfs}/opt/existing.json", - ], - ) - self.assertIn( - f"mountpoints={rootfs}/opt/existing.json {rootfs}/etc/app/settings.json " - f"{rootfs}/workspace {self.runtime}/shares\n", - result.stdout, - ) - - # A directory is no file mount point, a file no directory one, and - # the runtime binds the machine ID itself. - (self.rootfs / "dir").mkdir() - (self.rootfs / "file").write_text("x", encoding="utf-8") - for child, message in ( - ("0,/dir,ro,file", "not a regular file"), - ("0,/file/settings.json,ro,file", "not a directory"), - ("0,/file,ro", "not a directory"), - ("0,/etc/machine-id,ro,file", "reserved"), - ("0,/etc/machine-id/child,ro", "reserved"), - ("0,/run,ro", "reserved"), - ("0,/run/nvx,ro", "reserved"), - ("0,/run/nvx/time/state,ro", "reserved"), - ): - with self.subTest(child=child): - result, log = self._run(self._aggregate(child)) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn(message, result.stderr) - self.assertEqual(log, "") - - def test_agent_rejects_repeated_children_and_overlapping_targets(self): - for children, message in ( - (("0,/workspace,rw", "0,/opt/tools,ro"), "child is not unique"), - (("0,/workspace,rw", "1,/workspace,ro"), "targets overlap"), - (("0,/workspace,rw", "1,/workspace/cache,ro"), "targets overlap"), - (("0,/opt/tools/node,ro", "1,/opt,rw"), "targets overlap"), - ): - with self.subTest(children=children): - result, log = self._run(self._aggregate(*children)) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn(message, result.stderr) - # Every child is checked before anything is mounted. - self.assertEqual(log, "") - - # A shared prefix that is not a path component is not an overlap. - result, log = self._run(self._aggregate("0,/work,rw", "1,/workspace,ro")) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(len(log.splitlines()), 5) - - def test_agent_parses_every_share_before_mounting_any(self): - for cmdline in ( - "virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw " - "virtfs_dir=/opt/tools", - "virtfs_tag=microvm virtfs_mode=ro", - "virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw " - "virtfs_dir= virtfs_tag=microvm virtfs_mode=ro", - "virtfs_aggregate=1", - "nvx_share=0,/workspace,rw", - ): - with self.subTest(cmdline=cmdline): - result, log = self._run(cmdline) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn("bootstrap is incomplete", result.stderr) - self.assertEqual(log, "") - - def test_agent_fatal_unmounts_mounted_shares(self): - mount_log = self.root / "mount.log" - functions = "".join( - _shell_function(self.source, name) - for name in ("unmount_live_shares", "fatal", *self.SHARE_FUNCTIONS) - ).replace("/sbin/nvx-exit", "nvx_exit") - result = subprocess.run( - [ - self.shell, - "-s", - "--", - self.rootfs.as_posix(), - self._aggregate("0,/workspace,rw", "1,/opt/tools,ro"), - mount_log.as_posix(), - self.runtime, - ], - input=( - "set -eu\n" - "rootfs=$1\ncmdline=$2\nmount_log=$3\nruntime=$4\n" - "share_mountpoints=\n" - 'mount() { printf "mount %s\\n" "$*" >>"$mount_log"; ' - 'case "$*" in *shares/1*) return 1 ;; esac; }\n' - 'umount() { printf "umount %s\\n" "$1" >>"$mount_log"; }\n' - 'nvx_exit() { printf "exit %s\\n" "$1" >>"$mount_log"; exit "$1"; }\n' - f"{functions}mount_live_shares\n" - ), - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn("failed to bind live share 1", result.stderr) - rootfs = self.rootfs.as_posix() - self.assertEqual( - mount_log.read_text(encoding="utf-8").splitlines(), - [ - f"mount -t virtiofs -o rw,nosuid,nodev microvm {self.runtime}/shares", - f"mount -o bind {self.runtime}/shares/0 {rootfs}/workspace", - f"mount -o remount,bind,rw,nosuid,nodev {rootfs}/workspace", - f"mount -o bind {self.runtime}/shares/1 {rootfs}/opt/tools", - f"umount {rootfs}/workspace", - f"umount {self.runtime}/shares", - "exit 125", - ], - ) - - def test_agent_fails_closed_for_invalid_share_bootstrap(self): - (self.rootfs / "file").write_text("x", encoding="utf-8") - for cmdline, message in ( - ("virtfs_dir=/workspace", "incomplete"), - ("virtfs_dir=/workspace virtfs_tag=microvm", "incomplete"), - ("virtfs_dir=/workspace virtfs_tag=bad/tag virtfs_mode=rw", "tag"), - ("virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rx", "mode"), - ("virtfs_dir=workspace virtfs_tag=microvm virtfs_mode=rw", "absolute"), - ("virtfs_dir=/ virtfs_tag=microvm virtfs_mode=rw", "canonical"), - ("virtfs_dir=/a//b virtfs_tag=microvm virtfs_mode=rw", "canonical"), - ("virtfs_dir=/a/ virtfs_tag=microvm virtfs_mode=rw", "canonical"), - ("virtfs_dir=/a/./b virtfs_tag=microvm virtfs_mode=rw", "canonical"), - ("virtfs_dir=/a/../b virtfs_tag=microvm virtfs_mode=rw", "canonical"), - ("virtfs_dir=/a=b virtfs_tag=microvm virtfs_mode=rw", "reserved char"), - ('virtfs_dir=/a"b virtfs_tag=microvm virtfs_mode=rw', "reserved char"), - ("virtfs_dir=/proc virtfs_tag=microvm virtfs_mode=rw", "reserved"), - ("virtfs_dir=/sys/fs virtfs_tag=microvm virtfs_mode=rw", "reserved"), - ("virtfs_dir=/dev/shm virtfs_tag=microvm virtfs_mode=rw", "reserved"), - ("virtfs_dir=/.nvx-agent virtfs_tag=microvm virtfs_mode=ro", "reserved"), - ("virtfs_dir=/etc virtfs_tag=microvm virtfs_mode=ro", "reserved"), - ("virtfs_dir=/file virtfs_tag=microvm virtfs_mode=ro", "not a directory"), - ( - "virtfs_dir=/workspace virtfs_tag=microvm virtfs_mode=rw " - "nvx_share=0,/workspace,rw", - "require an aggregate", - ), - ( - "virtfs_dir=/shares virtfs_tag=microvm virtfs_mode=rw " - "virtfs_aggregate=1 nvx_share=0,/workspace,rw", - "must be mounted at", - ), - (self._aggregate(), "has no children"), - (self._aggregate(",/workspace,rw"), "child is invalid"), - (self._aggregate(".,/workspace,rw"), "child is invalid"), - (self._aggregate("..,/workspace,rw"), "child is invalid"), - (self._aggregate("a/b,/workspace,rw"), "child is invalid"), - (self._aggregate("a:b,/workspace,rw"), "child is invalid"), - (self._aggregate("a*,/workspace,rw"), "child is invalid"), - (self._aggregate("0,/workspace"), "child is invalid"), - (self._aggregate("0,/workspace,rx"), "mode"), - (self._aggregate("0,workspace,rw"), "absolute"), - (self._aggregate("0,/a,b,rw"), "reserved char"), - (self._aggregate('0,/a"b,rw', "1,/c,ro"), "reserved char"), - (self._aggregate("0,/workspace,rw", "1,/proc,ro"), "reserved"), - (self._aggregate("0,/file,ro"), "not a directory"), - ): - with self.subTest(cmdline=cmdline): - result, log = self._run(cmdline) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn("live share", result.stderr) - self.assertIn(message, result.stderr) - self.assertEqual(log, "") - - def test_agent_rejects_symbolic_link_in_share_target(self): - outside = self.root / "outside" - outside.mkdir() - try: - (self.rootfs / "workspace").symlink_to(outside, target_is_directory=True) - except OSError as error: - self.skipTest(f"symbolic links are unavailable: {error}") - result, log = self._run( - "virtfs_dir=/workspace/project virtfs_tag=microvm virtfs_mode=rw" - ) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertIn("crosses a symbolic link", result.stderr) - self.assertEqual(log, "") - self.assertFalse((outside / "project").exists()) - - -class SandboxRootTokenAgentTests(unittest.TestCase): - AGENT = SandboxShareAgentTests.AGENT - FIXTURE = ( - Path(__file__).parents[1] / "nvx-rs" / "tests" / "fixtures" / "root-tokens.json" - ) - UUID = "0123abcd-4567-89ef-0123-456789abcdef" - # nvx::protocol::root refuses a RAM upper beside a scratch block device, - # which it finds in the virtio_mmio.device= parameters of the command line - # as the kernel reads them. The shell init reads the tokens of those cases, - # and refuses the device that the kernel lists in /proc/iomem instead. - SCRATCH_DEVICE_ERROR = ( - "a RAM upper takes no scratch block device, but the command line adds a " - "virtio-mmio device at 0xd0006000" - ) - - def setUp(self): - shell = _posix_shell() - if shell is None: - self.skipTest("POSIX shell is unavailable") - self.shell = shell - self.source = self.AGENT.read_text(encoding="utf-8") - temporary = tempfile.TemporaryDirectory() - self.addCleanup(temporary.cleanup) - self.root = Path(temporary.name) - - def _read(self, command_line: str) -> subprocess.CompletedProcess[str]: - """Runs the agent's reader of the root's boot tokens on `command_line`, - and prints the UUID that it expects of the distro layer, the upper, and - the RAM upper's cap.""" - functions = "".join( - _shell_function(self.source, name) - for name in ("is_canonical_uuid", "read_layer_token", "read_root_tokens") - ) - # Windows drops a newline from a process argument, so the command line - # comes from a file, as /proc/cmdline holds it. - path = self.root / "cmdline" - path.write_bytes(command_line.encode()) - return subprocess.run( - [self.shell, "-s", "--", path.as_posix()], - input=( - "set -eu\n" - 'cmdline=$(cat "$1")\n' - 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' - f"{functions}" - "read_root_tokens\n" - 'echo "layer_uuid=$layer_uuid upper=$upper ram_percent=$ram_percent"\n' - ), - text=True, - capture_output=True, - check=False, - ) - - def test_agent_reads_the_boot_tokens_as_nvx_rs_reads_them(self): - cases = json.loads(self.FIXTURE.read_text(encoding="utf-8"))["cases"] - accepted: set[str] = set() - capped = devices = 0 - for case in cases: - with self.subTest(case=case["name"]): - result = self._read(case["command_line"]) - layout = case.get("layout") - if case.get("error") == self.SCRATCH_DEVICE_ERROR: - self.assertNotIn("shell_error", case) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertRegex( - result.stdout, r"\Alayer_uuid=\S* upper=ram ram_percent=\d*\n\Z" - ) - devices += 1 - continue - # The shell init assembles an EROFS image for trusted lowers, and - # cannot check that a scratch has no journal. - if ( - layout is not None - and layout["format"] == "erofs" - and layout.get("overlay") != "untrusted-lowers" - and layout.get("journal") != "forbidden" - ): - self.assertNotIn("shell_error", case) - self.assertEqual(result.returncode, 0, result.stderr) - identity = layout["identity"] or "" - cap = layout.get("max_memory_percent", "") - self.assertEqual( - result.stdout, - f"layer_uuid={identity} upper={layout['upper']} ram_percent={cap}\n", - ) - accepted.add(layout["upper"]) - capped += cap != "" - continue - self.assertEqual(result.returncode, 125, result.stdout) - self.assertRegex(result.stderr, r"\AFATAL: [^\n]+\n\Z") - self.assertIn(case.get("shell_error", ""), result.stderr) - self.assertEqual(accepted, {"scratch", "ram"}) - self.assertGreater(capped, 0) - self.assertGreater(devices, 0) - - def test_agent_explains_the_roots_that_it_cannot_assemble(self): - for command_line, message in ( - ( - f"nvx_layer=distro,0xd0003000,{self.UUID} nvx_scratch=0xd0006000,ext4", - "the nvx_layer kernel parameter names no image format, erofs or " - f"gpt: 'distro,0xd0003000,{self.UUID}'", - ), - ( - f"nvx_layer=distro,0xd0003000,gpt:{self.UUID} nvx_overlay_upper=ramfs", - f"nvx_layer=distro,0xd0003000,gpt:{self.UUID} names a GPT image, " - "which this init cannot assemble: it assembles only an EROFS image", - ), - ( - "nvx_overlay_mode=untrusted-lowers nvx_layer=distro,0xd0003000,erofs " - "nvx_overlay_upper=ramfs", - "nvx_overlay_mode=untrusted-lowers names untrusted lowers, which " - "this init cannot assemble: it mounts the overlay only for trusted " - "lowers, with metacopy=on,xino=on", - ), - ( - "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4 " - "nvx_scratch_journal=forbidden", - "nvx_scratch_journal=forbidden forbids the scratch a journal, which " - "this init cannot check: it reads nothing of the scratch's superblock", - ), - ): - with self.subTest(command_line=command_line): - result = self._read(command_line) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertEqual(result.stderr, f"FATAL: {message}\n") - - def test_agent_refuses_the_settings_with_the_messages_of_nvx_rs(self): - scratch = "nvx_layer=distro,0xd0003000,erofs nvx_scratch=0xd0006000,ext4" - ram = "nvx_layer=distro,0xd0003000,erofs nvx_overlay_upper=ramfs" - number = "a decimal number from 1 to {}, without a sign or leading zeros" - percent, bound = number.format(90), number.format(2**64 - 1) - for command_line, message in ( - ( - f"{scratch} nvx_scratch_journal", - "the nvx_scratch_journal kernel parameter has no value", - ), - ( - f"{ram} nvx_ram_upper_percent=5 nvx_ram_upper_percent=5", - "the nvx_ram_upper_percent kernel parameter is repeated", - ), - ( - f"{scratch} nvx_overlay_mode=trusted", - "the nvx_overlay_mode kernel parameter must be trusted-lowers or " - "untrusted-lowers, not 'trusted'", - ), - ( - f"{scratch} nvx_scratch_journal=", - "the nvx_scratch_journal kernel parameter must be allowed or " - "forbidden, not ''", - ), - ( - f"{ram} nvx_ram_upper_percent=91", - f"the nvx_ram_upper_percent kernel parameter must be {percent}, not '91'", - ), - ( - f"{ram} nvx_ram_upper_percent=09", - f"the nvx_ram_upper_percent kernel parameter must be {percent}, not '09'", - ), - ( - f"nvx_first_partition_bound=-1 {scratch}", - f"the nvx_first_partition_bound kernel parameter must be {bound}, " - "not '-1'", - ), - ( - f"{ram} nvx_scratch_journal=allowed", - "the nvx_scratch_journal kernel parameter needs a scratch upper, " - "nvx_scratch", - ), - ( - f"{scratch} nvx_ram_upper_percent=50", - "the nvx_ram_upper_percent kernel parameter needs a RAM upper, " - "nvx_overlay_upper", - ), - ( - f"{scratch} nvx_first_partition_bound=4096", - "the nvx_first_partition_bound kernel parameter needs a GPT image", - ), - # The parser's refusals come before those of what this init cannot - # assemble. - ( - f"{scratch} nvx_overlay_mode=untrusted-lowers nvx_scratch_journal=no", - "the nvx_scratch_journal kernel parameter must be allowed or " - "forbidden, not 'no'", - ), - ): - with self.subTest(command_line=command_line): - result = self._read(command_line) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertEqual(result.stderr, f"FATAL: {message}\n") - - def test_agent_requires_one_upper(self): - layer = f"nvx_layer=distro,0xd0003000,erofs:{self.UUID}" - for command_line, message in ( - ( - f"{layer} nvx_overlay_upper=ramfs nvx_scratch=0xd0006000,ext4", - "a layered root has one upper, but the command line names both " - "nvx_scratch and nvx_overlay_upper", - ), - ( - f"quiet {layer}", - "the nvx_layer kernel parameter needs an upper, nvx_scratch or " - "nvx_overlay_upper", - ), - ( - f"{layer} nvx_overlay_upper", - "the nvx_overlay_upper kernel parameter has no value", - ), - ( - f"{layer} nvx_overlay_upper=tmpfs", - "the nvx_overlay_upper kernel parameter must be ramfs, not 'tmpfs'", - ), - ( - f"{layer} nvx_overlay_upper=ramfs nvx_overlay_upper=ramfs", - "the nvx_overlay_upper kernel parameter is repeated", - ), - ): - with self.subTest(command_line=command_line): - result = self._read(command_line) - self.assertEqual(result.returncode, 125, result.stdout) - self.assertEqual(result.stderr, f"FATAL: {message}\n") - - def test_agent_refuses_a_scratch_device_beside_a_ram_upper(self): - iomem = self.root / "iomem" - function = _shell_function(self.source, "refuse_scratch_device").replace( - "/proc/iomem", iomem.as_posix() - ) - for content, refused in ( - ("", False), - ( - "d0003000-d0003fff : virtio-mmio.0\n" - " d0003000-d0003fff : virtio-mmio.0\n" - "d0007000-d0007fff : virtio-mmio.1\n", - False, - ), - # Only a virtio-mmio device whose window starts at the base counts. - ("d0006000-d0006fff : reserved\n", False), - ("d0005000-d0006fff : virtio-mmio.2\n", False), - ( - "d0003000-d0003fff : virtio-mmio.0\n" - "d0006000-d0006fff : virtio-mmio.1\n" - " d0006000-d0006fff : virtio-mmio.1\n", - True, - ), - ): - with self.subTest(content=content): - iomem.write_text(content, encoding="utf-8") - result = subprocess.run( - [self.shell, "-s"], - input=( - "set -eu\n" - 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' - f"{function}refuse_scratch_device\necho accepted\n" - ), - text=True, - capture_output=True, - check=False, - ) - if refused: - self.assertEqual(result.returncode, 125, result.stdout) - self.assertEqual( - result.stderr, - "FATAL: a RAM upper takes no scratch block device, but the " - "kernel has a virtio-mmio device at 0xd0006000\n", - ) - else: - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, "accepted\n") - - def test_agent_checks_a_ram_upper_before_it_mounts_anything(self): - check = self.source.index('\n[ "$upper" = scratch ] || refuse_scratch_device\n') - self.assertLess(self.source.index("\nread_root_tokens\n"), check) - self.assertLess(check, self.source.index("\nmount_layer distro ")) - self.assertLess( - self.source.index("\nmount_layer distro "), - self.source.index("\nmount_upper\n"), - ) - self.assertIn( - "upperdir=$upper_point/upper,workdir=$upper_point/work,metacopy=on,xino=on", - self.source, - ) - - def test_agent_mounts_the_upper_that_the_tokens_name(self): - runtime = (self.root / "run").as_posix() - log = self.root / "commands.log" - for upper, ram_percent, commands, point, name in ( - ( - "scratch", - "", - [ - f"mkdir -p {runtime}/scratch", - "resolve 0xd0006000", - "blockdev --flushbufs /dev/vdb", - f"mount -t ext4 -o rw,nosuid,nodev /dev/vdb {runtime}/scratch", - f"mkdir -p {runtime}/scratch/upper {runtime}/scratch/work", - ], - f"{runtime}/scratch", - "scratch", - ), - ( - "ram", - "", - [ - f"mkdir -p {runtime}/tmpfs", - f"mount -t tmpfs -o mode=0755,nosuid,nodev tmpfs {runtime}/tmpfs", - f"mkdir -p {runtime}/tmpfs/upper {runtime}/tmpfs/work", - ], - f"{runtime}/tmpfs", - "the tmpfs upper", - ), - ( - "ram", - "25", - [ - f"mkdir -p {runtime}/tmpfs", - "mount -t tmpfs -o mode=0755,nosuid,nodev,size=25% tmpfs " - f"{runtime}/tmpfs", - f"mkdir -p {runtime}/tmpfs/upper {runtime}/tmpfs/work", - ], - f"{runtime}/tmpfs", - "the tmpfs upper", - ), - ): - with self.subTest(upper=upper, ram_percent=ram_percent): - log.unlink(missing_ok=True) - result = subprocess.run( - [ - self.shell, - "-s", - "--", - runtime, - log.as_posix(), - upper, - ram_percent, - ], - input=( - "set -eu\n" - "runtime=$1\nlog=$2\nupper=$3\nram_percent=$4\n" - "scratch=$runtime/scratch\ntmpfs=$runtime/tmpfs\n" - 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' - 'resolve_block_device() { echo "resolve $*" >>"$log"; ' - "echo /dev/vdb; }\n" - 'blockdev() { echo "blockdev $*" >>"$log"; }\n' - 'mount() { echo "mount $*" >>"$log"; }\n' - 'mkdir() { echo "mkdir $*" >>"$log"; }\n' - f"{_shell_function(self.source, 'mount_upper')}" - "mount_upper\n" - 'echo "upper_point=$upper_point upper_name=$upper_name"\n' - ), - text=True, - capture_output=True, - check=False, - ) - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual( - result.stdout, f"upper_point={point} upper_name={name}\n" - ) - self.assertEqual(log.read_text(encoding="utf-8").splitlines(), commands) - - def test_agent_checks_the_layer_uuid_only_when_the_token_names_one(self): - log = self.root / "commands.log" - for expected, outcome in ( - ("", "mounted\n"), - (self.UUID, "mounted\n"), - ("fedcba98-7654-3210-fedc-ba9876543210", None), - ): - with self.subTest(expected=expected): - log.unlink(missing_ok=True) - result = subprocess.run( - [ - self.shell, - "-s", - "--", - self.root.as_posix(), - log.as_posix(), - expected, - ], - input=( - "set -eu\n" - "layers=$1\nlog=$2\n" - 'fatal() { echo "FATAL: $*" >&2; exit 125; }\n' - "resolve_block_device() { echo /dev/vda; }\n" - 'blockdev() { echo "blockdev $*" >>"$log"; }\n' - 'mount() { echo "mount $*" >>"$log"; }\n' - 'blkid() { echo "blkid $*" >>"$log"; ' - f"echo {self.UUID}; }}\n" - f"{_shell_function(self.source, 'mount_layer')}" - 'mount_layer distro 0xd0003000 "$3" ""\n' - "echo mounted\n" - ), - text=True, - capture_output=True, - check=False, - ) - commands = log.read_text(encoding="utf-8").splitlines() - self.assertEqual( - [command.split()[0] for command in commands], - ["blockdev", "mount", "blkid"] - if expected - else ["blockdev", "mount"], - ) - if outcome is None: - self.assertEqual(result.returncode, 125, result.stdout) - self.assertEqual( - result.stderr, - f"FATAL: distro layer UUID is {self.UUID}, expected " - f"{expected}\n", - ) - else: - self.assertEqual(result.returncode, 0, result.stderr) - self.assertEqual(result.stdout, outcome) - @unittest.skipIf(os.name == "nt", "requires POSIX symbolic links") class SandboxSmokeShareTests(unittest.TestCase): From a8730286a580ba7ef0ca933456f42de6bb819cad Mon Sep 17 00:00:00 2001 From: Pedro Henrique Penna Date: Thu, 12 Nov 2026 02:35:59 -0800 Subject: [PATCH 2/2] guest: delete the managed agent's sandbox mode Only the old layered init started the managed agent with a root other than its own, `-`: the sandbox mode, in which the agent entered each workload into the container's assembled root through guest::entry, held it at a barrier until it had placed it in the container cgroup that the old init prepared, and after STOP unmounted its host mappings and returned to that init. With the old init gone, nothing starts the mode. The agent now refuses any ROOTFS but `-` with status 125, as it does a wrong number of arguments, and runs only as the direct agent, whose loop nvx-init's layered roots and Fresh captures share. Its mapping table and its direct, layered and capture modes stay as they are; only the sandbox mode's use of the table goes, with TableRoot::Sandbox and the teardown that returned to the init. Deleted with the mode: the agent's cgroup module, the sandbox entry, the barrier and cgroup placement of each workload, the sandbox launch command, the seam's spawn, entry preparation and runtime binds and their paths, the AgentConfig fields that only the mode read (rootfs, hostname, direct and entry), and the teardown's diagnostic. So does what the warning-denied builds then find unused: RestoreHandoff::hostname, which only the sandbox entry read, through AgentConfig::hostname; Execution::has_open_output and Execution::wait in guest::exec, and Child::from_process in guest::process, which only the sandbox mode called; and GuestFeatures::SANDBOX_AGENT in the guest feature, since only the fake OpenVMM still announces it, so the testing feature alone gates it. Among the test helpers, so do Prepared::simulated in guest::entry's tests, with Simulation::Skip, which only it used, and PreparedChild's accessors program, arguments, environment, physical_pwd, physical_pwd_index, has_cgroup and barrier_entry_and_session, with the impl that held them, in guest::sys::spawn's tests, which only the sandbox launch tests read. Tests: - The agent's tests.rs retires sandbox_config with its three cases, the_sandbox_entry_takes_an_existing_account_and_one_machine_id_per_boot, a_refused_sandbox_entry_generates_nothing_and_stops_the_agent and a_failed_runtime_bind_stops_the_sandbox_agent_before_its_session, and the_agent_refuses_any_root_but_its_own checks the refusal. - dispatch/tests.rs retires the sandbox mode's mapping and launch tests: sandbox_mappings_mount_in_order_beneath_its_root_and_unblock_workloads, sandbox_mappings_refuse_the_targets_of_the_container_runtime, a_failed_sandbox_mapping_keeps_refusing_workloads, a_sandbox_without_mappings_maps_nothing_and_admits_workloads, sandbox_does_not_launch_when_an_environment_export_fails, sandbox_launch_places_its_workload_before_releasing_it, sandbox_launch_reports_unusable_directories_and_programs_as_its_helper_did and sandbox_launch_fails_on_its_entry_and_its_placement_without_running. - launch/tests.rs retires sandbox_launch_runs_the_workload_itself_through_its_entry, sandbox_launch_applies_exact_and_layered_environments_in_order, sandbox_pwd_is_physical_only_for_noncanonical_names_without_an_entry and sandbox_launch_reports_a_simulated_stage_without_privileges. - mappings/tests.rs retires the teardown's tests, a_sandbox_table_unmounts_in_reverse_order, a_sandbox_table_reports_each_mapping_that_stays_mounted and a_table_without_mappings_has_nothing_to_tear_down. - environment_failures_stop_direct_and_sandbox_launches_before_exec becomes environment_failures_stop_direct_launches_before_exec, with the direct agent's cases alone. - guest::exec's a_status_that_another_collector_took_first_is_left_to_it loses its case of Execution::wait, which goes. - AgentConfig::for_test builds only with the cli feature, as the dispatch tests that call it do, and the host CLI's features_of_a_guest_that_predates_them_are_none only with the testing feature, as the SANDBOX_AGENT that it announces does. - The kept tests lose what only the sandbox mode had: the harness's mode, the sandbox cases of agent_advertises_the_control_features_of_its_mode_and_stops and the_root_of_the_export_is_refused_though_it_decodes, the checks that the direct agent creates nothing under the deleted cgroup root, and the entry count of prepared_layered_mappings_and_cli_identity_use_the_existing_dispatcher. a_layered_table_that_announces_mappings_needs_its_retained_export builds its layered root without the sandbox agent's helper. Docs: the agent's rustdoc, that of guest, guest::mappings, guest::exec and guest::spawn, and the nvx README no longer describe the sandbox mode, or the agent as a caller of the process modules. The case matrix names guest::exec's doc tests by the lines to which they move. Part of #1123 (SL7.8b). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- doc/acceptance-case-matrix.md | 4 +- nvx-rs/README.md | 29 +- nvx-rs/src/bin/nvx-managed-agent.rs | 3 +- nvx-rs/src/guest/entry/tests.rs | 20 - nvx-rs/src/guest/exec.rs | 30 +- nvx-rs/src/guest/exec/tests.rs | 16 +- nvx-rs/src/guest/init/boot.rs | 4 - nvx-rs/src/guest/managed_agent/cgroup.rs | 14 - nvx-rs/src/guest/managed_agent/diagnostics.rs | 9 - nvx-rs/src/guest/managed_agent/dispatch.rs | 24 +- .../src/guest/managed_agent/dispatch/tests.rs | 438 +++--------------- .../managed_agent/dispatch/tests/runtime.rs | 8 +- nvx-rs/src/guest/managed_agent/exec.rs | 227 +-------- nvx-rs/src/guest/managed_agent/exec/tests.rs | 59 +-- nvx-rs/src/guest/managed_agent/launch.rs | 63 +-- .../src/guest/managed_agent/launch/tests.rs | 181 +------- nvx-rs/src/guest/managed_agent/mappings.rs | 61 +-- .../src/guest/managed_agent/mappings/tests.rs | 102 +--- nvx-rs/src/guest/managed_agent/mod.rs | 107 +---- nvx-rs/src/guest/managed_agent/seam.rs | 52 --- nvx-rs/src/guest/managed_agent/seam/tests.rs | 68 +-- nvx-rs/src/guest/managed_agent/tests.rs | 117 +---- .../managed_agent/tests/loopback/tests.rs | 8 - nvx-rs/src/guest/mappings.rs | 5 +- nvx-rs/src/guest/mappings/table.rs | 4 +- nvx-rs/src/guest/mod.rs | 4 +- nvx-rs/src/guest/process.rs | 9 - nvx-rs/src/guest/spawn.rs | 2 +- nvx-rs/src/guest/sys/entry/tests.rs | 4 +- nvx-rs/src/guest/sys/spawn/tests.rs | 43 -- nvx-rs/src/host/cli/session/tests.rs | 5 +- nvx-rs/src/protocol/features.rs | 11 +- 32 files changed, 192 insertions(+), 1539 deletions(-) delete mode 100644 nvx-rs/src/guest/managed_agent/cgroup.rs diff --git a/doc/acceptance-case-matrix.md b/doc/acceptance-case-matrix.md index d37c2cacb..fdd71a1cc 100644 --- a/doc/acceptance-case-matrix.md +++ b/doc/acceptance-case-matrix.md @@ -482,8 +482,8 @@ this matrix. The README's own code is no doctest. | `nvx-rs/src/guest/activation/state.rs - guest::activation::state::Activation (line 56)` | kept | | | `nvx-rs/src/guest/cgroup.rs - guest::cgroup (line 36)` | kept | Its module leaves with the exec engine (#1087), whose PR retires it | | `nvx-rs/src/guest/entry.rs - guest::entry (line 90)` | kept | Its module leaves with the exec engine (#1087), whose PR retires it | -| `nvx-rs/src/guest/exec.rs - guest::exec (line 80)` | kept | Its module leaves with the exec engine (#1087), whose PR retires it | -| `nvx-rs/src/guest/exec.rs - guest::exec::Execution::adopt (line 620)` | kept | Its module leaves with the exec engine (#1087), whose PR retires it | +| `nvx-rs/src/guest/exec.rs - guest::exec (line 79)` | kept | Its module leaves with the exec engine (#1087), whose PR retires it | +| `nvx-rs/src/guest/exec.rs - guest::exec::Execution::adopt (line 619)` | kept | Its module leaves with the exec engine (#1087), whose PR retires it | | `nvx-rs/src/guest/gpt.rs - guest::gpt (line 18)` | kept | The guest's space becomes private (#1127), whose PR moves it to the space's unit tests | | `nvx-rs/src/guest/mappings/mapper.rs - guest::mappings::mapper (line 107)` | kept | The guest's space becomes private (#1127), whose PR moves it to the space's unit tests | | `nvx-rs/src/guest/mappings/table.rs - guest::mappings::table::MappingTable (line 47)` | kept | | diff --git a/nvx-rs/README.md b/nvx-rs/README.md index 3b25c4e16..37b40d53f 100644 --- a/nvx-rs/README.md +++ b/nvx-rs/README.md @@ -1178,11 +1178,10 @@ entry, and refuses every later part. The table is complete once every announced mapping is applied, and none failed, and the root's teardown tears it down. Linux 6.8 or later. NVX's managed agent applies its host mapping table through -a `MappingTable` and a mapper, beneath `/` in direct mode and beneath the -container's assembled root in sandbox mode; the table refuses an empty source, -since the export is an aggregate, whose root lists its children, and NVX's -hosts, whose exports are all aggregates, send none. A layered `Root` applies its -live shares and mappings through a mapper. +a `MappingTable` and a mapper, beneath `/` in direct mode; the table refuses an +empty source, since the export is an aggregate, whose root lists its children, +and NVX's hosts, whose exports are all aggregates, send none. A layered `Root` +applies its live shares and mappings through a mapper. ### `nvx::guest::nvxs` @@ -1215,8 +1214,6 @@ failed and why. Each stage keeps its number, `LaunchStage::code`, which `LaunchStage::from_code` turns back into the stage, and a new stage takes a number of its own. -NVX's managed agent is one caller. - ### `nvx::guest::entry` `Entry`: the execution that a program enters, for any guest init or agent, from @@ -1297,8 +1294,6 @@ program alone, by a pidfd, or takes the status from the caller's collector with `observe`; with `Options::caller_reaps`, that collector is the program's only one. -NVX's managed agent is one caller. - ### `nvx::guest::time` x86_64: `TimeAbi`, the boot steps of NVX time ABI v1 as typed calls in the @@ -2313,8 +2308,7 @@ fails with which names the missing features. `nvx.py` does not ask; this, the checks of the [distro binding](#distro-bindings) and of the [scratch binding](#images-by-path), and the claim of the scratch image are the -intended changes of behavior. The -guest agents of NVX advertise bits 0, 4, and 6 in sandbox mode. +intended changes of behavior. #### Other differences from nvx.py @@ -2700,14 +2694,11 @@ through the mapper, and tears it down. nvx-init's plain-process core: each child resets its priority, drops its credentials and capabilities, and enters its working directory before exec. Each execution ends its process group, including on normal completion, without -cgroups, namespaces, limits, or a privilege-dropping helper. The legacy sandbox -mode keeps its root entry, launch barrier, and cgroup placement. Both apply -their host mapping table through -`mappings`, from its mount of the host's export, beneath `/` in direct mode and -beneath the container's assembled root in sandbox mode, where it unmounts the -table before it returns to the init agent, and any other guest init or agent -can use them too. Any guest init or agent can also run the boot steps of -`nvx-time` and the steps of `nvx-reseed` in its own process, through the +cgroups, namespaces, limits, or a privilege-dropping helper. It applies its +host mapping table through `mappings`, from its mount of the host's export, +beneath `/` in direct mode, and any other guest init or agent can use them too. +Any guest init or agent can also run the boot steps of `nvx-time` and the steps +of `nvx-reseed` in its own process, through the `nvx::guest::time` and `nvx::guest::reseed` calls, on which both programs run, and a template's guest captures before its root's assembly through `nvx::guest::snapshot::capture`, which runs the steps of `nvx-time pre-capture` diff --git a/nvx-rs/src/bin/nvx-managed-agent.rs b/nvx-rs/src/bin/nvx-managed-agent.rs index 3644b9605..ba02fbe98 100644 --- a/nvx-rs/src/bin/nvx-managed-agent.rs +++ b/nvx-rs/src/bin/nvx-managed-agent.rs @@ -1,6 +1,5 @@ //! The managed guest agent. It serves NVX's authenticated control session on the guest's control -//! TTY and runs one workload at a time, either as the guest's PID 1 or in a sandbox's root -//! filesystem. +//! TTY and runs one workload at a time as the guest's PID 1. //! //! `nvx-managed-agent CONTROL_TTY ROOTFS HOSTNAME UID GID USER HOME` starts the agent. //! See `nvx::guest::managed_agent`. diff --git a/nvx-rs/src/guest/entry/tests.rs b/nvx-rs/src/guest/entry/tests.rs index ac5bba7ba..9821173c9 100644 --- a/nvx-rs/src/guest/entry/tests.rs +++ b/nvx-rs/src/guest/entry/tests.rs @@ -21,26 +21,6 @@ use crate::guest::sys::tests::Simulation; use crate::guest::sys::{ChildStatus, Errno}; use crate::protocol::cmdline::write_resolver; -impl Prepared { - /// Returns an execution for tests without privileges, whose child starts without new - /// namespaces and skips the system calls of every entry stage, so that it keeps the test's - /// own root and identity, except that the stage of `failure`, if any, fails with its error. - pub(crate) fn simulated(failure: Option<(crate::guest::spawn::LaunchStage, Errno)>) -> Self { - Self { - child: EntryChild { - root: c"/".to_owned(), - hostname: DEFAULT_HOSTNAME.as_bytes().into(), - simulated: Some(match failure { - Some((stage, error)) => sys::tests::Simulation::Fail(stage, error), - None => sys::tests::Simulation::Skip, - }), - ..EntryChild::default() - }, - account: None, - } - } -} - /// Returns an entry of a root at `/run/nvx/rootfs` as `identity`, whose account is numeric, so /// that checking it reads nothing. fn entry(identity: Identity) -> Entry { diff --git a/nvx-rs/src/guest/exec.rs b/nvx-rs/src/guest/exec.rs index fcf4933df..5700634d1 100644 --- a/nvx-rs/src/guest/exec.rs +++ b/nvx-rs/src/guest/exec.rs @@ -10,8 +10,7 @@ //! //! It needs neither NVX's control protocol nor an async runtime, and it keeps no policy: the //! caller decides which programs run and how many at once, where their output goes, how much of -//! it to take, and what each outcome means. NVX's managed agent is one caller, which runs one -//! execution at a time and forwards its output and outcome as NVXC frames. +//! it to take, and what each outcome means. //! //! # Driving //! @@ -691,13 +690,6 @@ impl Execution { self.child.status() } - /// Returns whether either output stream is still open, for a caller that, as NVX's managed - /// agent does, drives the execution until the program's status is known and its output - /// closed, and then takes its last events. - pub(crate) fn has_open_output(&self) -> bool { - self.outputs.iter().any(Output::is_open) - } - /// Lets the execution read `bytes` more of `stream`: its flow-control seam. It reads a /// stream only while the stream has an allowance, and each output event uses up as much of /// it. An allowance that reaches `usize::MAX` never runs out. @@ -857,26 +849,6 @@ impl Execution { observed } - /// Waits until the program ends, collects its status, and returns it, for a caller that - /// leaves the status to the execution, and must stop at once once it has ended the program. - /// A collector of the caller's that took the status first makes it fail with `ECHILD`, as - /// [`next_event`](Self::next_event) does, after which the execution leaves the status to that - /// collector, and treats the program as ended. - pub(crate) fn wait(&mut self) -> io::Result { - match self.child.wait() { - Ok(status) => { - self.exited(); - Ok(status) - } - Err(error) => { - if error.raw_os_error() == Some(Errno::ECHILD as i32) { - self.taken(); - } - Err(error) - } - } - } - /// Returns the descriptors that move the execution on, each with what it waits on, which the /// caller polls for reading: any event on one, such as a hangup, means that the next event /// may follow. They change as the execution proceeds, and none is left once it has ended. diff --git a/nvx-rs/src/guest/exec/tests.rs b/nvx-rs/src/guest/exec/tests.rs index 376a125ac..7d8b8f360 100644 --- a/nvx-rs/src/guest/exec/tests.rs +++ b/nvx-rs/src/guest/exec/tests.rs @@ -707,8 +707,8 @@ fn ending_the_program_ends_the_process_group_that_it_leads() { fn a_status_that_another_collector_took_first_is_left_to_it() { let temporary = tempfile::tempdir().unwrap(); let file = temporary.path().join("descendant"); - // Before the execution looks for the status itself, the caller may cancel it, or wait for it. - for (cancel_first, wait_first) in [(false, false), (true, false), (false, true)] { + // Before the execution looks for the status itself, the caller may cancel it. + for cancel_first in [false, true] { let mut execution = start( leave_a_descendant(&file, 5), false, @@ -723,10 +723,6 @@ fn a_status_that_another_collector_took_first_is_left_to_it() { if cancel_first { assert!(!execution.cancel(), "nothing to cancel"); } - if wait_first { - let error = execution.wait().unwrap_err(); - assert_eq!(error.raw_os_error(), Some(Errno::ECHILD as i32)); - } execution.grant(Stream::Stdout, MAX_EVENT_BYTES); execution.grant(Stream::Stderr, MAX_EVENT_BYTES); let mut record = Record::default(); @@ -750,12 +746,8 @@ fn a_status_that_another_collector_took_first_is_left_to_it() { } } assert!(execution.next_event(&mut buffer).unwrap().is_none()); - // Collecting fails once, unless the cancellation or the wait found the status gone first. - assert_eq!( - failures, - usize::from(!cancel_first && !wait_first), - "{cancel_first} {wait_first}" - ); + // Collecting fails once, unless the cancellation found the status gone first. + assert_eq!(failures, usize::from(!cancel_first), "{cancel_first}"); assert!(execution.status().is_none()); assert!(execution.deadline().is_none()); assert!( diff --git a/nvx-rs/src/guest/init/boot.rs b/nvx-rs/src/guest/init/boot.rs index 35429a636..149c2966a 100644 --- a/nvx-rs/src/guest/init/boot.rs +++ b/nvx-rs/src/guest/init/boot.rs @@ -343,7 +343,6 @@ pub(crate) struct RestoreHandoff { pub(crate) workload: protocol::workload::WorkloadIdentity, pub(crate) user: CString, pub(crate) home: CString, - pub(crate) hostname: CString, pub(crate) proxy: Option, pub(crate) table: MappingTable, pub(crate) mapper: Mapper, @@ -470,9 +469,6 @@ impl Restored { workload, user, home, - hostname: CString::new(applied.hostname()) - .map_err(activation_error) - .map_err(classed(status::ACTIVATION_WORKLOAD))?, proxy: applied.proxy().map(str::to_owned), table: u32::try_from(record.configuration().mappings) .ok() diff --git a/nvx-rs/src/guest/managed_agent/cgroup.rs b/nvx-rs/src/guest/managed_agent/cgroup.rs deleted file mode 100644 index cc71d86df..000000000 --- a/nvx-rs/src/guest/managed_agent/cgroup.rs +++ /dev/null @@ -1,14 +0,0 @@ -use crate::guest::cgroup::{ExecutionCgroup, Layout}; -use crate::guest::sys::{self, Errno, ProcessId}; - -use super::seam::System; - -/// Moves a sandbox launcher into the container cgroup. -pub(crate) fn write_container_pid(system: &impl System, child: ProcessId) -> Result<(), Errno> { - let root = &system.paths().cgroup_root; - let layout = Layout::new(root.join("agent"), root.join("container")) - .map_err(|error| sys::errno_of(&error))?; - ExecutionCgroup::open(&layout, None) - .and_then(|container| container.place(child.raw().unsigned_abs())) - .map_err(|error| sys::errno_of(&error)) -} diff --git a/nvx-rs/src/guest/managed_agent/diagnostics.rs b/nvx-rs/src/guest/managed_agent/diagnostics.rs index c34bb2e79..d802aeb2d 100644 --- a/nvx-rs/src/guest/managed_agent/diagnostics.rs +++ b/nvx-rs/src/guest/managed_agent/diagnostics.rs @@ -15,15 +15,6 @@ pub(crate) fn mapping_error(system: &mut impl System, status: i32, entry: u32) { write_stderr(message.as_bytes()); } -/// Reports a host mapping that stays mounted after the table's teardown through both -/// diagnostic channels. -pub(crate) fn unmapping_error(system: &mut impl System, status: i32, entry: u32) { - error(system, "host-unmapping", status); - let message = - format!("NVX-MANAGED-ERROR: stage=host-unmapping status={status} entry={entry}\n"); - write_stderr(message.as_bytes()); -} - /// Writes a diagnostic to the process's standard error, ignoring write failures. pub(crate) fn write_stderr(message: &[u8]) { sys::write_standard_error(message); diff --git a/nvx-rs/src/guest/managed_agent/dispatch.rs b/nvx-rs/src/guest/managed_agent/dispatch.rs index b4dd6cddb..af0749ead 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch.rs @@ -36,15 +36,11 @@ impl AgentConfig { launch::parse_id(uid)?; launch::parse_id(gid)?; Ok(Self { - rootfs: c"-".to_owned(), - hostname: c"nvx-managed".to_owned(), uid: sys::c_string(uid)?, gid: sys::c_string(gid)?, user, home, restored_proxy: None, - direct: true, - entry: None, }) } @@ -66,15 +62,11 @@ impl AgentConfig { control: FileDescriptor, ) -> Result { let config = Self { - rootfs: c"-".to_owned(), - hostname: c"".to_owned(), uid: c"0".to_owned(), gid: c"0".to_owned(), user: c"".to_owned(), home: c"".to_owned(), restored_proxy: None, - direct: true, - entry: None, }; config.serve_init(runtime, control, HostMappings::default(), true) } @@ -221,15 +213,11 @@ fn handle_data( if let Some(handoff) = session.activate(request)? { *mappings = HostMappings::layered(handoff.table, handoff.mapper, handoff.export)?; *restored = Some(AgentConfig { - rootfs: c"-".to_owned(), - hostname: handoff.hostname, uid: CString::new(handoff.workload.uid.to_string()).expect("decimal UID"), gid: CString::new(handoff.workload.gid.to_string()).expect("decimal GID"), user: handoff.user, home: handoff.home, restored_proxy: Some(handoff.proxy), - direct: true, - entry: None, }); } } @@ -281,12 +269,12 @@ fn handle_data( } APP_FEATURES => { if request.payload.is_empty() { - let features = if config.direct { - GuestFeatures::DIRECT_AGENT - } else { - GuestFeatures::SANDBOX_AGENT - }; - session.send_app(APP_READY, request.request_id, 0, &features.encode())?; + session.send_app( + APP_READY, + request.request_id, + 0, + &GuestFeatures::DIRECT_AGENT.encode(), + )?; } else { session.send_error(request.request_id, Errno::EINVAL as i32, INVALID_REQUEST)?; } diff --git a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs index c13a26730..4eb84a829 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch/tests.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch/tests.rs @@ -42,35 +42,21 @@ struct Harness { } impl Harness { - fn new(direct: bool) -> Self { - Self::with_system(direct, TestSystem::new(Paths::default()), 0) + fn new() -> Self { + Self::with_system(TestSystem::new(Paths::default()), 0) } - fn with_system(direct: bool, system: TestSystem, expected_mappings: u32) -> Self { - let mut harness = Self::spawn(direct, system, expected_mappings); + fn with_system(system: TestSystem, expected_mappings: u32) -> Self { + let mut harness = Self::spawn(system, expected_mappings); harness.reset(1, 7); harness } - /// Starts a sandbox agent whose assembled root is `rootfs`, with a table of - /// `expected_mappings` announced mappings, and resets its session. - fn in_sandbox(rootfs: &Path, system: TestSystem, expected_mappings: u32) -> Self { - let mut harness = Self::spawn_at(Some(rootfs), system, expected_mappings); - harness.reset(1, 7); - harness - } - - /// Starts the agent, which attaches and waits for the broker's first RESET. - fn spawn(direct: bool, system: TestSystem, expected_mappings: u32) -> Self { - let rootfs = (!direct).then_some(Path::new("/rootfs")); - Self::spawn_at(rootfs, system, expected_mappings) - } - - /// Starts a direct agent, or a sandbox agent whose assembled root is `rootfs`, which - /// attaches and waits for the broker's first RESET. - fn spawn_at(rootfs: Option<&Path>, system: TestSystem, expected_mappings: u32) -> Self { - let config = AgentConfig::for_test(rootfs); - let mappings = HostMappings::announced_for(&config, expected_mappings); + /// Starts the agent, with a table of `expected_mappings` announced mappings, which attaches + /// and waits for the broker's first RESET. + fn spawn(system: TestSystem, expected_mappings: u32) -> Self { + let config = AgentConfig::for_test(); + let mappings = HostMappings::announced(expected_mappings); Self::spawn_with(system, config, mappings) } @@ -306,30 +292,23 @@ fn assert_environment_failure(mut harness: Harness, diagnostic_prefix: &[u8]) { #[test] fn agent_advertises_the_control_features_of_its_mode_and_stops() { - for (direct, expected) in [ - (true, GuestFeatures::DIRECT_AGENT), - (false, GuestFeatures::SANDBOX_AGENT), - ] { - let mut harness = Harness::new(direct); - let response = harness.request(APP_FEATURES, &[]); - assert_eq!( - (response.kind, response.request_id, response.status), - (APP_READY, 42, 0) - ); - let features = GuestFeatures::decode(&response.payload).unwrap(); - assert_eq!(features, expected); - if direct { - assert!(!features.contains(GuestFeatures::EXEC_CGROUP)); - } - let system = harness.stop(); - assert_eq!(system.tty_drained, 1); - } + let mut harness = Harness::new(); + let response = harness.request(APP_FEATURES, &[]); + assert_eq!( + (response.kind, response.request_id, response.status), + (APP_READY, 42, 0) + ); + let features = GuestFeatures::decode(&response.payload).unwrap(); + assert_eq!(features, GuestFeatures::DIRECT_AGENT); + assert!(!features.contains(GuestFeatures::EXEC_CGROUP)); + let system = harness.stop(); + assert_eq!(system.tty_drained, 1); } #[test] fn fresh_session_refuses_root_requests_and_routes_capture_before_stopping() { - let config = AgentConfig::for_test(None); - let mappings = HostMappings::announced_for(&config, 0); + let config = AgentConfig::for_test(); + let mappings = HostMappings::announced(0); let mut harness = Harness::spawn_with_capture( TestSystem::new(Paths::default()), config, @@ -370,7 +349,7 @@ fn fresh_session_refuses_root_requests_and_routes_capture_before_stopping() { #[test] fn agent_refuses_feature_payloads_and_unannounced_mapping_tables() { - let mut harness = Harness::new(true); + let mut harness = Harness::new(); for (kind, payload) in [ (APP_FEATURES, b"abc".as_slice()), (APP_MAPS, b"\0\0\0\0\x01\0\0\0\0\0\x01\0\x02\0".as_slice()), @@ -406,7 +385,7 @@ fn mappings_mount_in_order_and_unblock_workloads_through_dispatch() { None, ); let second = maps_payload(2, &[(b"1", out.as_os_str().as_bytes(), 0)], None); - let mut harness = Harness::with_system(true, system, 3); + let mut harness = Harness::with_system(system, 3); let mut replies = Vec::new(); replies.extend(harness.request_all(1, APP_EXEC, &[])); replies.extend(harness.request_all(2, APP_MAPS, &first)); @@ -443,8 +422,8 @@ fn mappings_mount_in_order_and_unblock_workloads_through_dispatch() { #[test] fn init_refuses_activation_without_a_restore_and_keeps_serving() { for fresh in [true, false] { - let config = AgentConfig::for_test(None); - let mut mappings = HostMappings::announced_for(&config, 0); + let config = AgentConfig::for_test(); + let mut mappings = HostMappings::announced(0); let mut system = TestSystem::new(Paths::default()); let (host, guest) = UnixStream::pair().unwrap(); let thread = thread::spawn(move || { @@ -509,8 +488,8 @@ fn restored_ready_maps_and_first_exec_share_the_existing_start_transition() -> T &[(b"1", target.join("out").as_os_str().as_bytes(), 0)], None, ); - let config = AgentConfig::for_test(None); - let mut mappings = HostMappings::announced_for(&config, 2); + let config = AgentConfig::for_test(); + let mut mappings = HostMappings::announced(2); let (mut capture, payload) = Capture::activation_fixture(); let activation = capture.restored.as_mut().unwrap(); assert!(matches!( @@ -580,8 +559,8 @@ fn restored_ready_maps_and_first_exec_share_the_existing_start_transition() -> T /// request, what Ready reports, and the workload's process. fn running_restored_workload(system: TestSystem) -> (Harness, Vec, Vec, sys::ProcessId) { use crate::guest::activation::ActivationAction; - let config = AgentConfig::for_test(None); - let mappings = HostMappings::announced_for(&config, 0); + let config = AgentConfig::for_test(); + let mappings = HostMappings::announced(0); let (mut capture, payload) = Capture::activation_fixture(); let restored = capture.restored.as_mut().unwrap(); let now = std::time::Instant::now(); @@ -676,7 +655,7 @@ fn mapping_sequence_errors_are_reported_through_dispatch() { let moved = target.display().to_string(); let target = target.as_os_str().as_bytes(); let entry = [(b"0/src".as_slice(), target, 0)]; - let mut harness = Harness::with_system(true, system, 1); + let mut harness = Harness::with_system(system, 1); let mut replies = Vec::new(); replies.extend(harness.request_all(1, APP_MAPS, &maps_payload(1, &entry, None))); replies.extend(harness.request_all( @@ -710,7 +689,7 @@ fn a_failed_mapping_keeps_refusing_workloads_through_dispatch() { .join("guest/missing"); let missing = maps_payload(0, &[(b"0/missing", target.as_os_str().as_bytes(), 0)], None); let valid = maps_payload(0, &[(b"0/src", target.as_os_str().as_bytes(), 0)], None); - let mut harness = Harness::with_system(true, system, 2); + let mut harness = Harness::with_system(system, 2); let mut replies = Vec::new(); replies.extend(harness.request_all(1, APP_MAPS, &missing)); replies.extend(harness.request_all(2, APP_EXEC, &[])); @@ -733,7 +712,7 @@ fn unannounced_mapping_tables_are_refused_through_dispatch() { let (temporary, system) = TestSystem::with_host_export(); let target = temporary.path().canonicalize().unwrap().join("guest/src"); let mapping = maps_payload(0, &[(b"0/src", target.as_os_str().as_bytes(), 0)], None); - let mut harness = Harness::with_system(true, system, 0); + let mut harness = Harness::with_system(system, 0); assert_eq!( harness.request_all(1, APP_MAPS, &mapping), vec![response( @@ -852,7 +831,6 @@ fn prepared_layered_mappings_and_cli_identity_use_the_existing_dispatcher() -> T .collect(); assert_eq!(system.kernel.calls(), expected); assert_eq!(system.spawn_calls, 1); - assert_eq!(system.entries_prepared, 0); Ok(()) } @@ -885,313 +863,6 @@ fn a_layered_root_without_an_export_refuses_maps_but_keeps_its_complete_table() assert!(system.kernel.calls().is_empty()); } -#[test] -fn sandbox_mappings_mount_in_order_beneath_its_root_and_unblock_workloads() { - let (_temporary, system, rootfs) = sandbox_root(); - let export = system.paths.hostfs_dir.join("root").canonicalize().unwrap(); - // The targets are the container's paths, below its assembled root, even below the - // runtime's exact paths, such as `/etc`. - let first = maps_payload( - 0, - &[ - (b"0/src", b"/workspace", protocol::MAP_READ_ONLY), - (b"0/notes.txt", b"/etc/app/notes.txt", 0), - ], - None, - ); - let second = maps_payload(2, &[(b"1", b"/out", 0)], None); - let mut harness = Harness::in_sandbox(&rootfs, system, 3); - let mut replies = Vec::new(); - replies.extend(harness.request_all(1, APP_EXEC, &[])); - replies.extend(harness.request_all(2, APP_MAPS, &first)); - replies.extend(harness.request_all(3, APP_EXEC, &[])); - replies.extend(harness.request_all(4, APP_MAPS, &second)); - replies.extend(harness.request_all(5, APP_EXEC, &[])); - assert_eq!( - replies, - vec![ - response(APP_ERROR, 1, Errno::EAGAIN as i32, b"mappings-incomplete"), - response(APP_READY, 2, 0, b""), - response(APP_ERROR, 3, Errno::EAGAIN as i32, b"mappings-incomplete"), - response(APP_READY, 4, 0, b""), - // With the table complete, the request reaches its decoder. - response(APP_ERROR, 5, Errno::EINVAL as i32, b"invalid-request"), - ] - ); - - let system = harness.stop(); - let [workspace, notes, out] = - ["workspace", "etc/app/notes.txt", "out"].map(|target| rootfs.join(target)); - let expected: Vec<_> = [ - (export.join("0/src"), &workspace, Access::ReadOnly), - (export.join("0/notes.txt"), ¬es, Access::ReadWrite), - (export.join("1"), &out, Access::ReadWrite), - ] - .into_iter() - .flat_map(|(from, to, access)| attach_calls(&from, to, access)) - .collect(); - assert_eq!(system.kernel.calls(), expected); - assert!(workspace.is_dir() && notes.is_file() && out.is_dir()); - assert_eq!(system.spawn_calls, 0); -} - -#[test] -fn sandbox_mappings_refuse_the_targets_of_the_container_runtime() { - // The runtime's trees, their ancestors, and its exact paths. - for target in [ - &b"/proc"[..], - b"/dev/shm", - b"/.nvx-agent", - b"/etc/machine-id", - b"/etc/resolv.conf", - b"/run/nvx/time/state", - b"/run/nvx", - b"/run", - b"/etc", - ] { - let (_temporary, system, rootfs) = sandbox_root(); - let mapping = maps_payload(0, &[(b"0/src", target, 0)], None); - let mut harness = Harness::in_sandbox(&rootfs, system, 1); - let mut replies = Vec::new(); - replies.extend(harness.request_all(1, APP_MAPS, &mapping)); - replies.extend(harness.request_all(2, APP_EXEC, &[])); - assert_eq!( - replies, - vec![ - response(APP_ERROR, 1, Errno::EPERM as i32, b"mapping-failed"), - response(APP_ERROR, 2, Errno::EAGAIN as i32, b"mappings-incomplete"), - ], - "{}", - String::from_utf8_lossy(target) - ); - let system = harness.stop(); - assert!(system.kernel.calls().is_empty()); - assert_eq!(std::fs::read_dir(&rootfs).unwrap().count(), 0); - } -} - -#[test] -fn a_failed_sandbox_mapping_keeps_refusing_workloads() { - let (_temporary, system, rootfs) = sandbox_root(); - let missing = maps_payload(0, &[(b"0/missing", b"/workspace", 0)], None); - let valid = maps_payload(0, &[(b"0/src", b"/workspace", 0)], None); - let mut harness = Harness::in_sandbox(&rootfs, system, 2); - let mut replies = Vec::new(); - replies.extend(harness.request_all(1, APP_MAPS, &missing)); - replies.extend(harness.request_all(2, APP_EXEC, &[])); - replies.extend(harness.request_all(3, APP_MAPS, &valid)); - replies.extend(harness.request_all(4, APP_EXEC, &[])); - assert_eq!( - replies, - vec![ - response(APP_ERROR, 1, Errno::ENOENT as i32, b"mapping-failed"), - response(APP_ERROR, 2, Errno::EAGAIN as i32, b"mappings-incomplete"), - response(APP_ERROR, 3, Errno::EINVAL as i32, b"invalid-request"), - response(APP_ERROR, 4, Errno::EAGAIN as i32, b"mappings-incomplete"), - ] - ); - let system = harness.stop(); - assert!(system.kernel.calls().is_empty()); - assert_eq!(system.spawn_calls, 0); -} - -#[test] -fn a_sandbox_without_mappings_maps_nothing_and_admits_workloads() { - let (_temporary, system, rootfs) = sandbox_root(); - let mapping = maps_payload(0, &[(b"0/src", b"/workspace", 0)], None); - let mut harness = Harness::in_sandbox(&rootfs, system, 0); - let mut replies = Vec::new(); - replies.extend(harness.request_all(1, APP_MAPS, &mapping)); - replies.extend(harness.request_all(2, APP_EXEC, &[])); - assert_eq!( - replies, - vec![ - response(APP_ERROR, 1, Errno::EINVAL as i32, b"invalid-request"), - // No table holds the request back, so it reaches its decoder. - response(APP_ERROR, 2, Errno::EINVAL as i32, b"invalid-request"), - ] - ); - let system = harness.stop(); - assert!(system.kernel.calls().is_empty()); - assert_eq!(std::fs::read_dir(&rootfs).unwrap().count(), 0); -} - -#[test] -fn sandbox_does_not_launch_when_an_environment_export_fails() { - let mut system = TestSystem::new(Paths::default()); - system.fail_environment = Some(b"HOME".to_vec()); - assert_environment_failure( - Harness::with_system(false, system, 0), - b"nvx-managed-agent: cannot configure workload environment: ", - ); -} - -/// Spawns a sandbox launch command, whose entry stages the test system simulates, without -/// privileges. -fn spawn_sandbox( - command: crate::guest::spawn::Command, -) -> Result { - command - .spawn() - .map_err(|error| crate::guest::sys::errno_of(&error)) -} - -/// Returns a sandbox agent's test system that spawns its workloads, with their entry stages -/// simulated, and the path of its `container` cgroup's membership file. -fn sandbox_system() -> (tempfile::TempDir, TestSystem, std::path::PathBuf) { - let (temporary, mut system) = TestSystem::with_container_cgroup(); - system.spawner = Some(spawn_sandbox); - let procs = system.paths.cgroup_root.join("container/cgroup.procs"); - (temporary, system, procs) -} - -#[test] -fn sandbox_launch_places_its_workload_before_releasing_it() -> TestResult { - if super::super::tests::isolate_process_test( - "NVX_DISPATCH_SANDBOX_PLACEMENT_TEST_CHILD", - "guest::managed_agent::dispatch::tests::sandbox_launch_places_its_workload_before_releasing_it", - ) { - return Ok(()); - } - let _process = super::super::tests::PROCESS_TEST_LOCK.lock()?; - let (_temporary, system, procs) = sandbox_system(); - // The workload sees its own process ID in the cgroup's membership file as soon as it - // runs, leads its own session, and gets the workload's environment. - let script = "[ \"$(cat \"$0\")\" = \"$$\" ] || exit 3; \ - set -- $(cat /proc/$$/stat); [ \"$6\" = \"$$\" ] || exit 4; \ - printf '%s %s %s %s' \"$HOME\" \"$USER\" \"$LOGNAME\" \"$PWD\""; - let mut harness = Harness::with_system(false, system, 0); - assert_eq!( - harness.request_all( - 42, - APP_EXEC, - &workload_payload(&["/bin/sh", "-c", script, utf8(&procs)?], Some("/tmp"))?, - ), - vec![ - response(APP_STDOUT, 42, 0, b"/home/workload workload workload /tmp"), - response(APP_EXIT, 42, 0, b"exit"), - ] - ); - let system = harness.stop(); - assert_eq!((system.entries_prepared, system.spawn_calls), (1, 1)); - assert!(system.diagnostics.is_empty()); - Ok(()) -} - -#[test] -fn sandbox_launch_reports_unusable_directories_and_programs_as_its_helper_did() -> TestResult { - if super::super::tests::isolate_process_test( - "NVX_DISPATCH_SANDBOX_REFUSAL_TEST_CHILD", - "guest::managed_agent::dispatch::tests::sandbox_launch_reports_unusable_directories_and_programs_as_its_helper_did", - ) { - return Ok(()); - } - let _process = super::super::tests::PROCESS_TEST_LOCK.lock()?; - let (temporary, system, _) = sandbox_system(); - let missing = temporary.path().join("missing"); - let missing = utf8(&missing)?; - let mut harness = Harness::with_system(false, system, 0); - let diagnostic = super::super::diagnostics::with_description( - format!("nvx-managed-agent: cannot use working directory {missing}: ").as_bytes(), - Errno::ENOENT, - ); - assert_eq!( - harness.request_all( - 42, - APP_EXEC, - &workload_payload(&["/bin/true"], Some(missing))? - ), - vec![ - response(APP_STDERR, 42, 0, &diagnostic), - response(APP_EXIT, 42, 125, b"exit"), - ] - ); - let diagnostic = super::super::diagnostics::with_description( - b"nvx-managed-agent: cannot execute workload: ", - Errno::ENOENT, - ); - assert_eq!( - harness.request_all(43, APP_EXEC, &workload_payload(&[missing], None)?), - vec![ - response(APP_STDERR, 43, 0, &diagnostic), - response(APP_EXIT, 43, 125, b"exit"), - ] - ); - let system = harness.stop(); - assert!(system.diagnostics.is_empty()); - Ok(()) -} - -#[test] -fn sandbox_launch_fails_on_its_entry_and_its_placement_without_running() -> TestResult { - if super::super::tests::isolate_process_test( - "NVX_DISPATCH_SANDBOX_ENTRY_TEST_CHILD", - "guest::managed_agent::dispatch::tests::sandbox_launch_fails_on_its_entry_and_its_placement_without_running", - ) { - return Ok(()); - } - let _process = super::super::tests::PROCESS_TEST_LOCK.lock()?; - let launch_failed = |request_id| { - response( - APP_ERROR, - request_id, - 125, - protocol::LAUNCH_FAILED.as_bytes(), - ) - }; - let (temporary, mut system, _) = sandbox_system(); - let marker = temporary.path().join("ran"); - let payload = workload_payload(&["/bin/touch", utf8(&marker)?], None)?; - - // A refused preparation names its reason and starts nothing. - system.refuse_entry = true; - let mut harness = Harness::with_system(false, system, 0); - let diagnostic = b"nvx-managed-agent: cannot prepare the workload's execution: the \ - account is unavailable: no passwd entry has the user ID\n"; - assert_eq!( - harness.request_all(42, APP_EXEC, &payload), - vec![response(APP_STDERR, 42, 0, diagnostic), launch_failed(42)] - ); - let system = harness.stop(); - assert_eq!((system.entries_prepared, system.spawn_calls), (1, 0)); - - // A failed entry stage, which names itself, and a failed placement, which never releases - // the workload, end the launch before the workload runs. - for (request_id, error) in [(43, Errno::EPERM), (44, Errno::ENOENT)] { - let (_cgroup, mut system, procs) = sandbox_system(); - let mut expected = Vec::new(); - if error == Errno::EPERM { - system.entry_failure = Some((crate::guest::spawn::LaunchStage::MoveRoot, error)); - expected.push(response( - APP_STDERR, - request_id, - 0, - b"nvx-managed-agent: cannot enter the workload's execution: the root move \ - launch stage failed: Operation not permitted (os error 1)\n", - )); - } else { - std::fs::remove_file(&procs)?; - } - expected.push(launch_failed(request_id)); - let mut harness = Harness::with_system(false, system, 0); - assert_eq!( - harness.request_all(request_id, APP_EXEC, &payload), - expected - ); - let system = harness.stop(); - assert_eq!( - system.diagnostics, - format!( - "NVX-MANAGED-ERROR: stage=exec-launch status={}\n", - error as i32 - ) - .as_bytes() - ); - } - assert!(!marker.exists()); - Ok(()) -} - #[test] fn direct_does_not_launch_when_identity_export_fails() { if super::super::tests::isolate_process_test( @@ -1204,7 +875,7 @@ fn direct_does_not_launch_when_identity_export_fails() { let (_temporary, mut system) = TestSystem::with_direct(); system.fail_environment = Some(b"HOME".to_vec()); assert_environment_failure( - Harness::with_system(true, system, 0), + Harness::with_system(system, 0), b"nvx-managed-agent: cannot configure workload environment: ", ); } @@ -1220,7 +891,7 @@ fn pwd_export_failures_refuse_launch_without_spawning() { let _process = super::super::tests::PROCESS_TEST_LOCK.lock().unwrap(); let (temporary, mut system) = TestSystem::with_direct(); system.fail_environment = Some(b"PWD".to_vec()); - let mut harness = Harness::with_system(true, system, 0); + let mut harness = Harness::with_system(system, 0); for (request_id, cwd) in [(42, None), (43, Some(temporary.path().to_str().unwrap()))] { assert_eq!( harness.request_all(request_id, APP_EXEC, &exec_payload(cwd)), @@ -1238,7 +909,7 @@ fn pwd_export_failures_refuse_launch_without_spawning() { #[test] fn agent_refuses_malformed_working_directories() { - let mut harness = Harness::new(true); + let mut harness = Harness::new(); let argument = [9u32.to_le_bytes().as_slice(), b"/bin/true"].concat(); let mut payload = Vec::new(); payload.extend_from_slice(&0u32.to_le_bytes()); @@ -1261,7 +932,7 @@ fn data_validation_diagnostics_preserve_the_c_failure_stage_and_status() { ); let frame = encode_app(APP_PING, 42, 0, &[]); let diagnostics = - Harness::new(true).fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 9, &frame).unwrap()); + Harness::new().fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 9, &frame).unwrap()); assert_eq!( diagnostics, [ @@ -1272,7 +943,7 @@ fn data_validation_diagnostics_preserve_the_c_failure_stage_and_status() { ); let diagnostics = - Harness::new(true).fail(&encode_outer(OUTER_DATA, &INSTANCE, 2, 8, &frame).unwrap()); + Harness::new().fail(&encode_outer(OUTER_DATA, &INSTANCE, 2, 8, &frame).unwrap()); assert_eq!( diagnostics, [ @@ -1283,7 +954,7 @@ fn data_validation_diagnostics_preserve_the_c_failure_stage_and_status() { ); let diagnostics = - Harness::new(true).fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 8, b"x").unwrap()); + Harness::new().fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 8, b"x").unwrap()); assert_eq!( diagnostics, [ @@ -1298,7 +969,7 @@ fn data_validation_diagnostics_preserve_the_c_failure_stage_and_status() { fn a_request_that_fails_after_validation_reports_the_data_stage() { let frame = encode_app(APP_STOP, 42, 0, b"x"); let diagnostics = - Harness::new(true).fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 8, &frame).unwrap()); + Harness::new().fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 8, &frame).unwrap()); assert_eq!( diagnostics, format!( @@ -1396,7 +1067,7 @@ fn direct_start_collects_pending_statuses_before_forking() -> TestResult { sys::reset_child_signal(); let (_temporary, mut system) = TestSystem::with_direct(); system.direct_starter = Some(start_after_pending_statuses_are_collected); - let mut harness = Harness::with_system(true, system, 0); + let mut harness = Harness::with_system(system, 0); let mut pending = std::process::Command::new("/bin/true").spawn()?; let process = sys::ProcessId::from_child(&pending); let deadline = std::time::Instant::now() + std::time::Duration::from_secs(20); @@ -1469,7 +1140,7 @@ fn failed_direct_starts_keep_their_child_until_collected_or_explicitly_lost() -> let (temporary, mut system) = TestSystem::with_direct(); system.direct_starter = Some(start_with_failed_pidfd_and_cleanup); let marker = temporary.path().join("failed-child"); - let mut harness = Harness::with_system(true, system, 0); + let mut harness = Harness::with_system(system, 0); harness .host .set_read_timeout(Some(std::time::Duration::from_secs(5)))?; @@ -1555,7 +1226,7 @@ fn direct_launch_refuses_working_directories_that_the_workload_cannot_enter() -> let unsearchable = Unsearchable::new(root.join("unsearchable"))?; let marker = root.join("ran"); let marker = utf8(&marker)?; - let mut harness = Harness::with_system(true, system, 0); + let mut harness = Harness::with_system(system, 0); for (request_id, directory, error) in [ (42, root.join("missing"), Errno::ENOENT), (43, file, Errno::ENOTDIR), @@ -1599,7 +1270,7 @@ fn direct_launch_refuses_a_failed_identity_change_and_runs_nothing() -> TestResu system.direct_starter = Some(super::super::seam::tests::start_with_failing_credentials); let marker = temporary.path().join("ran"); let payload = workload_payload(&["/bin/touch", utf8(&marker)?], None)?; - let mut harness = Harness::with_system(true, system, 0); + let mut harness = Harness::with_system(system, 0); assert_eq!( harness.request_all(42, APP_EXEC, &payload), vec![response( @@ -2017,7 +1688,7 @@ fn lost_status_harness() -> Result<(tempfile::TempDir, Harness), Box TestR std::os::unix::fs::symlink(&first, &link)?; let script = "if [ -d \"$1\" ]; then mv \"$1\" \"$2\"; fi; ln -sfn \"$4\" \"$3\"; pwd -P"; system.direct_starter = Some(super::super::seam::tests::start_as_privileged); - let mut harness = Harness::with_system(true, system, 0); + let mut harness = Harness::with_system(system, 0); let renamed = harness.request_all( 42, APP_EXEC, @@ -2166,7 +1837,7 @@ fn direct_launch_starts_the_workload_in_the_directory_that_it_checked() -> TestR fn direct_harness() -> (tempfile::TempDir, Harness) { let (temporary, mut system) = TestSystem::with_direct(); system.direct_starter = Some(super::super::seam::tests::start_as_privileged); - (temporary, Harness::with_system(true, system, 0)) + (temporary, Harness::with_system(system, 0)) } #[test] @@ -2224,7 +1895,6 @@ fn direct_core_applies_inherited_exact_and_layered_environments() -> TestResult b"/layered|/tmp|last|1", ); let system = harness.stop(); - assert!(!system.paths.cgroup_root.exists()); assert!(system.diagnostics.is_empty()); Ok(()) } @@ -2416,8 +2086,8 @@ fn rooted_capture_refuses_both_kill_flags_and_leaves_the_exec_group_running() -> let _process = super::super::tests::PROCESS_TEST_LOCK.lock()?; let (_temporary, mut system) = TestSystem::with_direct(); system.direct_starter = Some(super::super::seam::tests::start_as_privileged); - let config = AgentConfig::for_test(None); - let mappings = HostMappings::announced_for(&config, 0); + let config = AgentConfig::for_test(); + let mappings = HostMappings::announced(0); let mut harness = Harness::spawn_with_capture(system, config, mappings, Some(Capture::fixture(false))); harness.reset(1, 7); @@ -2549,7 +2219,7 @@ fn refused_records_report_the_stage_at_which_the_c_agent_reported_them() { ), ]; for (record, expected) in cases { - let diagnostics = Harness::new(true).fail(&record); + let diagnostics = Harness::new().fail(&record); let header = &record[..OUTER_HEADER_LEN]; assert_eq!( String::from_utf8(diagnostics).unwrap(), @@ -2561,7 +2231,7 @@ fn refused_records_report_the_stage_at_which_the_c_agent_reported_them() { #[test] fn data_before_the_first_reset_is_a_type_that_the_agent_does_not_take_yet() { - let harness = Harness::spawn(true, TestSystem::new(Paths::default()), 0); + let harness = Harness::spawn(TestSystem::new(Paths::default()), 0); let frame = encode_app(APP_PING, 42, 0, &[]); let diagnostics = harness.fail(&encode_outer(OUTER_DATA, &INSTANCE, 1, 0, &frame).unwrap()); assert_eq!( @@ -2574,7 +2244,7 @@ fn data_before_the_first_reset_is_a_type_that_the_agent_does_not_take_yet() { fn data_at_the_last_sequence_number_is_out_of_sequence() { // After a RESET at the second-to-last sequence number, DATA at the last one leaves none // for the broker's next record. - let mut harness = Harness::spawn(true, TestSystem::new(Paths::default()), 0); + let mut harness = Harness::spawn(TestSystem::new(Paths::default()), 0); harness.reset(1, u64::MAX - 1); let frame = encode_app(APP_PING, 42, 0, &[]); let record = encode_outer(OUTER_DATA, &INSTANCE, 1, u64::MAX, &frame).unwrap(); @@ -2597,14 +2267,14 @@ fn a_stream_that_ends_reports_a_read_failure_inside_a_record_or_not() { let frame = encode_app(APP_PING, 42, 0, &[]); let record = encode_outer(OUTER_DATA, &INSTANCE, 1, 8, &frame).unwrap(); for length in [0, 20, OUTER_HEADER_LEN + 1] { - let diagnostics = Harness::new(true).close(&record[..length]); + let diagnostics = Harness::new().close(&record[..length]); assert_eq!(diagnostics, expected.as_bytes(), "after {length} bytes"); } } #[test] fn a_reset_between_requests_starts_a_new_epoch() { - let mut harness = Harness::new(true); + let mut harness = Harness::new(); assert_eq!( harness.request(APP_PING, &[]), response(APP_READY, 42, 0, b"") diff --git a/nvx-rs/src/guest/managed_agent/dispatch/tests/runtime.rs b/nvx-rs/src/guest/managed_agent/dispatch/tests/runtime.rs index e9bfd863a..262a6ed36 100644 --- a/nvx-rs/src/guest/managed_agent/dispatch/tests/runtime.rs +++ b/nvx-rs/src/guest/managed_agent/dispatch/tests/runtime.rs @@ -398,7 +398,7 @@ fn privileged_layered_runtime_delivers_a_fragment_collected_status_before_driver let config = AgentConfig::layered(b"nvx_identity=1000:1000", c"workload".into(), c"/".into()) .unwrap(); - let mut mappings = HostMappings::announced_for(&config, 0); + let mut mappings = HostMappings::announced(0); assert!(matches!( run(&mut system, &mut session, &config, &mut mappings), Ok(Outcome::Stopped) @@ -482,7 +482,7 @@ fn privileged_layered_runtime_keeps_helper_policy_and_first_error_during_failed_ let config = AgentConfig::layered(b"nvx_identity=1000:1000", c"workload".into(), c"/".into()) .unwrap(); - let mut mappings = HostMappings::announced_for(&config, 0); + let mut mappings = HostMappings::announced(0); assert_eq!( run(&mut system, &mut session, &config, &mut mappings).err(), Some(Errno::ECHILD) @@ -655,7 +655,7 @@ fn privileged_layered_runtime_stops_before_attach_on_pending_sigterm() { let config = AgentConfig::layered(b"nvx_identity=1000:1000", c"workload".into(), c"/".into()) .unwrap(); - let mut mappings = HostMappings::announced_for(&config, 0); + let mut mappings = HostMappings::announced(0); assert!(matches!( run(&mut system, &mut session, &config, &mut mappings), Ok(Outcome::Stopped) @@ -770,7 +770,7 @@ fn privileged_layered_runtime_delivers_a_write_collected_status_before_driver_ev let config = AgentConfig::layered(b"nvx_identity=1000:1000", c"workload".into(), c"/".into()) .unwrap(); - let mut mappings = HostMappings::announced_for(&config, 0); + let mut mappings = HostMappings::announced(0); assert!(matches!( run(&mut system, &mut session, &config, &mut mappings), Ok(Outcome::Stopped) diff --git a/nvx-rs/src/guest/managed_agent/exec.rs b/nvx-rs/src/guest/managed_agent/exec.rs index c3064c61c..4decd40e6 100644 --- a/nvx-rs/src/guest/managed_agent/exec.rs +++ b/nvx-rs/src/guest/managed_agent/exec.rs @@ -1,21 +1,16 @@ use std::os::unix::process::ExitStatusExt; use std::time::{Duration, Instant}; -use crate::guest::entry; -use crate::guest::exec::{self as sandbox_exec, Execution, Options, Pipes}; use crate::guest::init::{self, Stream}; -use crate::guest::process::Child; -use crate::guest::spawn::{self as sandbox_spawn, Barrier}; -use crate::guest::sys::{self, Errno, FileDescriptor, ProcessId, c_string}; +use crate::guest::sys::{self, Errno, FileDescriptor, c_string}; use crate::protocol::{ self, APP_ACTIVATE, APP_CANCEL, APP_CAPTURE, APP_EXIT, APP_STDERR, APP_STDOUT, BUSY, CWD_FAILED, GuestExecPayload, LAUNCH_FAILED, OUTER_DATA, }; use super::AgentConfig; -use super::cgroup; use super::diagnostics::with_description; -use super::launch::{self, LaunchRequest}; +use super::launch; use super::seam::System; use super::session::{DataFailureKind, Event as SessionEvent, ReceiveFailure, Session}; @@ -28,12 +23,11 @@ const ENVIRONMENT_DIAGNOSTIC: &[u8] = b"nvx-managed-agent: cannot configure work /// Executes one validated workload request and streams its terminal outcome. /// -/// The agent drives its native plain-process workload, or its legacy sandbox [`Execution`], -/// in the C agent's loop: each turn polls the workload and -/// the control session, serves a control record, forwards the workload's output, collects the -/// statuses of the agent's children, and checks the workload's timeout. The agent keeps the C -/// agent's policy: the output cap, checked for each read of up to 32 KiB, and the precedence of -/// a cancellation, a timeout, and the cap over the workload's own status. +/// The agent drives its native plain-process workload in the C agent's loop: each turn polls the +/// workload and the control session, serves a control record, forwards the workload's output, +/// collects the statuses of the agent's children, and checks the workload's timeout. The agent +/// keeps the C agent's policy: the output cap, checked for each read of up to 32 KiB, and the +/// precedence of a cancellation, a timeout, and the cap over the workload's own status. pub(crate) fn run( system: &mut impl System, session: &mut Session, @@ -54,17 +48,9 @@ pub(crate) fn run( } } } - if config.direct { - session.collect(|_, _| {})?; - } + session.collect(|_, _| {})?; let mut workload = match launch(system, session, config, execution) { Ok(launched) => launched, - Err(LaunchError::Entry(error)) => { - let diagnostic = - format!("nvx-managed-agent: cannot prepare the workload's execution: {error}\n"); - session.send_app(APP_STDERR, request_id, 0, diagnostic.as_bytes())?; - return session.send_error(request_id, 125, LAUNCH_FAILED); - } Err(LaunchError::Start(failure)) => { settle_start(system, session, &failure)?; return session.send_error(request_id, 125, LAUNCH_FAILED); @@ -74,9 +60,7 @@ pub(crate) fn run( let started = Instant::now(); let mut state = RunState::default(); let mut buffer = [0u8; OUTPUT_CHUNK_BYTES]; - while state.running(&workload) - || (workload.has_open_output() && !(config.direct && state.ending())) - { + while state.running(&workload) || (workload.has_open_output() && !state.ending()) { let session_ready = match poll( &workload, (!state.session_lost).then_some(session.descriptor()), @@ -111,7 +95,7 @@ pub(crate) fn run( if session.stopping() { return Err(error); } - if !(config.direct && state.status_lost) + if !state.status_lost && let Err(failure) = workload.wait(session) { report_wait_failure(system, &failure); @@ -121,7 +105,7 @@ pub(crate) fn run( } } } - let output = if config.direct && (state.session_lost || state.output_limited) { + let output = if state.session_lost || state.output_limited { &mut [][..] } else { &mut buffer[..] @@ -135,17 +119,8 @@ pub(crate) fn run( state.timed_out = true; workload.terminate(); } - // The outcome is the cause for which the agent ended the workload, so a status that it - // cannot collect only ends the loop, once the output has closed. - if state.ending() - && state.running(&workload) - && !config.direct - && let Err(error) = workload.wait(session) - { - state.lose_status(system, &error); - } } - let outcome = if config.direct && state.ending() { + let outcome = if state.ending() { if !state.session_lost && !state.output_limited { state.forward(system, session, request_id, &mut workload, &mut buffer)?; } @@ -162,22 +137,9 @@ pub(crate) fn run( } match launch_report { Ok(Some(failure)) => match failure.kind() { - LaunchKind::WorkingDirectory if config.direct => { + LaunchKind::WorkingDirectory => { return refuse_cwd(session, request_id, execution, failure.raw_os_error()); } - LaunchKind::WorkingDirectory | LaunchKind::Exec if !config.direct => { - return refuse_sandbox_workload(session, request_id, execution, &failure); - } - // The sandbox agent's own stages, its entry's among them, fail its launch, which - // names the stage on standard error, as a refused preparation names its reason. - _ if !config.direct => { - super::diagnostics::error(system, "exec-launch", failure.raw_os_error()); - let diagnostic = format!( - "nvx-managed-agent: cannot enter the workload's execution: {failure}\n" - ); - session.send_app(APP_STDERR, request_id, 0, diagnostic.as_bytes())?; - return session.send_error(request_id, 125, LAUNCH_FAILED); - } LaunchKind::Credentials => { super::diagnostics::error(system, "exec-launch", failure.raw_os_error()); return session.send_error(request_id, 125, LAUNCH_FAILED); @@ -223,26 +185,23 @@ pub(crate) fn run( } } -/// The direct core and the unchanged sandbox driver, behind the same NVXC outcome policy. +/// The direct core, behind the NVXC outcome policy. enum Workload { Direct(init::Exec), - Sandbox(Execution), } -/// Events needed by the unchanged NVXC policy; only the sandbox translates legacy values. +/// Events needed by the unchanged NVXC policy. enum Event<'a> { Output(Stream, &'a [u8]), Launch(std::io::Result>), ReadFailed, DirectKillFailed(std::io::Error), - CgroupKillFailed(std::io::Error), Ended(std::process::ExitStatus), Other, } enum LaunchFailure { Direct(init::LaunchFailure), - Sandbox(sandbox_spawn::LaunchFailure), } enum LaunchKind { @@ -256,7 +215,6 @@ impl LaunchFailure { fn kind(&self) -> LaunchKind { let stage = match self { Self::Direct(failure) => failure.stage, - Self::Sandbox(failure) => failure.stage().code(), }; // The syscall boundary's stable child-report stage numbers. match stage { @@ -270,16 +228,6 @@ impl LaunchFailure { fn raw_os_error(&self) -> i32 { match self { Self::Direct(failure) => failure.error, - Self::Sandbox(failure) => failure.raw_os_error(), - } - } -} - -impl std::fmt::Display for LaunchFailure { - fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - match self { - Self::Direct(failure) => failure.fmt(formatter), - Self::Sandbox(failure) => failure.fmt(formatter), } } } @@ -288,21 +236,18 @@ impl Workload { fn status(&self) -> Option { match self { Self::Direct(workload) => workload.status(), - Self::Sandbox(workload) => workload.status(), } } fn has_open_output(&self) -> bool { match self { Self::Direct(workload) => workload.has_open_output(), - Self::Sandbox(workload) => workload.has_open_output(), } } fn descriptors(&self) -> Vec> { match self { Self::Direct(workload) => workload.descriptors().map(|(_, fd)| fd).collect(), - Self::Sandbox(workload) => workload.descriptors().map(|(_, fd)| fd).collect(), } } @@ -322,33 +267,12 @@ impl Workload { _ => Event::Other, }) }), - Self::Sandbox(workload) => workload.next_event(buffer).map(|event| { - event.map(|event| match event { - sandbox_exec::Event::Output(stream, bytes) => Event::Output( - match stream { - sandbox_exec::Stream::Stdout => Stream::Stdout, - sandbox_exec::Stream::Stderr => Stream::Stderr, - }, - bytes, - ), - sandbox_exec::Event::Launch(report) => { - Event::Launch(report.map(|failure| failure.map(LaunchFailure::Sandbox))) - } - sandbox_exec::Event::ReadFailed(..) => Event::ReadFailed, - sandbox_exec::Event::KillFailed(sandbox_exec::KillTarget::Cgroup, error) => { - Event::CgroupKillFailed(error) - } - sandbox_exec::Event::Ended(outcome) => Event::Ended(outcome.status()), - _ => Event::Other, - }) - }), } } fn observe(&mut self, id: u32, status: std::process::ExitStatus) { match self { Self::Direct(workload) => workload.observe(id, status), - Self::Sandbox(workload) => workload.observe(id, status), }; } @@ -359,42 +283,28 @@ impl Workload { fn terminate(&mut self) { match self { Self::Direct(workload) => workload.end(), - Self::Sandbox(workload) => { - workload.terminate(); - } } } fn cancel(&mut self) { match self { Self::Direct(workload) => workload.cancel(), - Self::Sandbox(workload) => { - workload.cancel(); - } } } fn disconnect(&mut self) { match self { Self::Direct(workload) => workload.reset(), - Self::Sandbox(workload) => workload.disconnect(), } } fn discard(&mut self, stream: Stream) { match self { Self::Direct(workload) => workload.discard(stream), - Self::Sandbox(workload) => workload.discard(match stream { - Stream::Stdout => sandbox_exec::Stream::Stdout, - Stream::Stderr => sandbox_exec::Stream::Stderr, - }), } } fn wait(&mut self, session: &Session) -> std::io::Result { - if let Self::Sandbox(workload) = self { - return workload.wait(); - } loop { self.collect(session)?; if let Some(status) = self.status() { @@ -418,12 +328,6 @@ impl Workload { } _ => Event::Other, }), - Self::Sandbox(workload) => workload.kill_failure().map(|event| match event { - sandbox_exec::Event::KillFailed(sandbox_exec::KillTarget::Cgroup, error) => { - Event::CgroupKillFailed(error) - } - _ => Event::Other, - }), } } } @@ -565,7 +469,6 @@ impl RunState { super::diagnostics::error(system, "exec-kill", sys::errno_of(&error) as i32); return Err(sys::errno_of(&error)); } - Event::CgroupKillFailed(error) => report_kill_failure(system, &error), Event::Ended(outcome) => self.outcome = Some(outcome), Event::Other => {} } @@ -615,21 +518,11 @@ fn poll(workload: &Workload, session: Option<&FileDescriptor>) -> Result report_kill_failure(system, &error), - Event::DirectKillFailed(error) => { - super::diagnostics::error(system, "exec-kill", sys::errno_of(&error) as i32); - } - _ => {} + if let Event::DirectKillFailed(error) = event { + super::diagnostics::error(system, "exec-kill", sys::errno_of(&error) as i32); } } } @@ -641,8 +534,6 @@ fn report_wait_failure(system: &mut impl System, error: &std::io::Error) { /// Why a workload did not launch. enum LaunchError { - /// The sandbox agent's entry refused to prepare the workload's execution. - Entry(entry::Error), /// A native failure that may still own a child until the agent collects it. Start(init::StartFailure), /// Any other failure, which the agent reports as a failed launch alone, as the C agent did. @@ -655,66 +546,18 @@ impl From for LaunchError { } } -/// Starts a native direct workload, or the unchanged sandbox entry and placement sequence. +/// Starts a native direct workload. fn launch( system: &mut impl System, session: &Session, config: &AgentConfig, execution: &GuestExecPayload, ) -> Result { - if config.direct { - let workload = system - .start_direct(launch::direct_request(config, execution)?) - .map_err(LaunchError::Start)?; - session.watch(workload.id()); - return Ok(Workload::Direct(workload)); - } - let (pipes, ends) = Pipes::new(false).map_err(|error| sys::errno_of(&error))?; - let request = LaunchRequest { - config, - execution, - stdout: FileDescriptor::from_owned(ends.stdout), - stderr: FileDescriptor::from_owned(ends.stderr), - }; - let entry = config.entry.as_ref().ok_or(Errno::EINVAL)?; - let prepared = system.prepare_entry(entry).map_err(LaunchError::Entry)?; - let command = launch::sandbox_command(system.paths(), request, prepared)?; - let mut spawned = system.spawn(command)?; - let process = spawned.process(); - let barrier = spawned.take_barrier(); - let mut workload = match Execution::new(spawned, pipes, Options::new().process_group()) { - Ok(mut workload) => { - workload.grant(sandbox_exec::Stream::Stdout, usize::MAX); - workload.grant(sandbox_exec::Stream::Stderr, usize::MAX); - Workload::Sandbox(workload) - } - Err(error) => { - drop(barrier); - super::diagnostics::error(system, "exec-launch", sys::errno_of(&error) as i32); - if let Err(error) = Child::from_process(process).wait() { - report_wait_failure(system, &error); - } - return Err(LaunchError::Other); - } - }; - if let Some(barrier) = barrier - && let Err(error) = place(system, process, barrier) - { - super::diagnostics::error(system, "exec-launch", error as i32); - workload.terminate(); - if let Err(error) = workload.wait(session) { - report_wait_failure(system, &error); - } - return Err(LaunchError::Other); - } - Ok(workload) -} - -/// Places a sandbox workload, which waits at its `barrier`, in the `container` cgroup, and then -/// releases it. A failure drops the barrier unreleased, which cancels the workload's launch. -fn place(system: &impl System, process: ProcessId, barrier: Barrier) -> Result<(), Errno> { - cgroup::write_container_pid(system, process)?; - barrier.release().map_err(|error| sys::errno_of(&error)) + let workload = system + .start_direct(launch::direct_request(config, execution)?) + .map_err(LaunchError::Start)?; + session.watch(workload.id()); + Ok(Workload::Direct(workload)) } /// Failure discovered before the fork boundary. @@ -765,30 +608,6 @@ fn validate_launch_environment( Ok(()) } -/// Reports a sandbox workload that could not enter its working directory, or whose program could -/// not run, as the sandbox agent's helper did: with its diagnostic on standard error, and with -/// status 125 as the workload's exit status. -fn refuse_sandbox_workload( - session: &mut Session, - request_id: u64, - execution: &GuestExecPayload, - failure: &LaunchFailure, -) -> Result<(), Errno> { - let prefix = if matches!(failure.kind(), LaunchKind::WorkingDirectory) { - [ - b"nvx-managed-agent: cannot use working directory ".as_slice(), - execution.cwd.as_deref().unwrap_or(b"/"), - b": ", - ] - .concat() - } else { - b"nvx-managed-agent: cannot execute workload: ".to_vec() - }; - let diagnostic = with_description(&prefix, Errno::from_raw(failure.raw_os_error())); - session.send_app(APP_STDERR, request_id, 0, &diagnostic)?; - session.send_app(APP_EXIT, request_id, 125, b"exit") -} - /// Sends the C-compatible stderr and application error for a cwd failure. fn refuse_cwd( session: &mut Session, diff --git a/nvx-rs/src/guest/managed_agent/exec/tests.rs b/nvx-rs/src/guest/managed_agent/exec/tests.rs index 1c5d5c3c7..6869e99ca 100644 --- a/nvx-rs/src/guest/managed_agent/exec/tests.rs +++ b/nvx-rs/src/guest/managed_agent/exec/tests.rs @@ -1,21 +1,14 @@ -use std::ffi::CString; - use super::super::seam::Paths; use super::super::seam::tests::TestSystem; use super::*; -use crate::guest::entry::{Entry, Identity}; -fn config(direct: bool) -> AgentConfig { +fn config() -> AgentConfig { AgentConfig { - rootfs: CString::new(if direct { "-" } else { "/rootfs" }).unwrap(), - hostname: c"test".to_owned(), uid: c"1000".to_owned(), gid: c"1000".to_owned(), user: c"workload".to_owned(), home: c"/home/workload".to_owned(), restored_proxy: None, - direct, - entry: (!direct).then(|| Entry::new("/rootfs", Identity::new(1000, 1000))), } } @@ -29,48 +22,38 @@ fn execution(environment: Option>>) -> GuestExecPayload { } } -fn validate( - direct: bool, - failing: &[u8], - execution: &GuestExecPayload, -) -> Result<(), EnvironmentFailure> { +fn validate(failing: &[u8], execution: &GuestExecPayload) -> Result<(), EnvironmentFailure> { let mut system = TestSystem::new(Paths::default()); system.fail_environment = Some(failing.to_vec()); - let result = validate_launch_environment(&mut system, &config(direct), execution); + let result = validate_launch_environment(&mut system, &config(), execution); assert!(system.execs.is_empty()); result } #[test] -fn environment_failures_stop_direct_and_sandbox_launches_before_exec() { - for direct in [true, false] { - for variable in [b"HOME".as_slice(), b"USER", b"LOGNAME"] { - let failure = validate(direct, variable, &execution(None)).unwrap_err(); - assert!( - matches!( - failure, - EnvironmentFailure::Child(Errno::ENOMEM, ENVIRONMENT_DIAGNOSTIC) - ), - "{failure:?}" - ); - } - let failure = validate(direct, b"PWD", &execution(None)).unwrap_err(); - assert!(matches!(failure, EnvironmentFailure::Launch(Errno::ENOMEM))); +fn environment_failures_stop_direct_launches_before_exec() { + for variable in [b"HOME".as_slice(), b"USER", b"LOGNAME"] { + let failure = validate(variable, &execution(None)).unwrap_err(); + assert!( + matches!( + failure, + EnvironmentFailure::Child(Errno::ENOMEM, ENVIRONMENT_DIAGNOSTIC) + ), + "{failure:?}" + ); } + let failure = validate(b"PWD", &execution(None)).unwrap_err(); + assert!(matches!(failure, EnvironmentFailure::Launch(Errno::ENOMEM))); let layered = execution(Some(vec![b"LAYERED=value".to_vec()])); - for direct in [true, false] { - let failure = validate(direct, b"LAYERED", &layered).unwrap_err(); - assert!(matches!( - failure, - EnvironmentFailure::Child(Errno::ENOMEM, ENVIRONMENT_DIAGNOSTIC) - )); - } + let failure = validate(b"LAYERED", &layered).unwrap_err(); + assert!(matches!( + failure, + EnvironmentFailure::Child(Errno::ENOMEM, ENVIRONMENT_DIAGNOSTIC) + )); // An exact environment replaces the workload's `PWD`, so none is validated. let exact = GuestExecPayload { inherit_default_env: false, ..execution(Some(Vec::new())) }; - for direct in [true, false] { - assert!(validate(direct, b"PWD", &exact).is_ok()); - } + assert!(validate(b"PWD", &exact).is_ok()); } diff --git a/nvx-rs/src/guest/managed_agent/launch.rs b/nvx-rs/src/guest/managed_agent/launch.rs index aaec6cb4a..6ed21abdc 100644 --- a/nvx-rs/src/guest/managed_agent/launch.rs +++ b/nvx-rs/src/guest/managed_agent/launch.rs @@ -1,27 +1,12 @@ use std::ffi::{CStr, CString}; -use crate::guest::entry::Prepared; use crate::guest::init::{AccountMode, ExecRequest}; use crate::guest::parse::parse_decimal; -use crate::guest::spawn::Command; -use crate::guest::sys::{self, Errno, FileDescriptor, Mode, OpenFlags, c_string}; +use crate::guest::sys::{Errno, c_string}; use crate::protocol::GuestExecPayload; use super::AgentConfig; use super::environment; -use super::seam::{Paths, path_c_string}; - -/// One workload's launch inputs: its borrowed request, and the descriptors that its child takes. -pub(crate) struct LaunchRequest<'a> { - /// Agent mode and configured workload identity. - pub(crate) config: &'a AgentConfig, - /// Decoded workload request. - pub(crate) execution: &'a GuestExecPayload, - /// Writer of the workload's standard output. - pub(crate) stdout: FileDescriptor, - /// Writer of the workload's standard error. - pub(crate) stderr: FileDescriptor, -} /// Describes one workload for the init-owned plain-process core. /// @@ -55,57 +40,11 @@ pub(crate) fn direct_request( }) } -/// Builds the command that launches one workload of the sandbox agent, which enters `prepared`. -/// -/// The workload's standard input is the null device, and its standard output and error are the -/// request's pipes. The child waits at its barrier until the agent has placed it in its cgroup, -/// and then enters the execution, as the first process of its PID namespace and the leader of a -/// new session, enters the working directory as the workload, records the physical path in `PWD` -/// for a noncanonical name, and runs the workload itself. The agent builds the environment as the -/// direct agent does: its own, with the workload's `HOME`, `USER`, `LOGNAME`, and -/// `PWD`, and then the request's entries in order, which replace it unless they layer over it. -pub(crate) fn sandbox_command( - paths: &Paths, - request: LaunchRequest<'_>, - prepared: Prepared, -) -> Result { - let execution = request.execution; - let directory = working_directory(execution)?; - let (environment, physical_pwd) = workload_environment(request.config, execution, &directory)?; - let arguments = execution - .argv - .iter() - .map(|argument| c_string(argument)) - .collect::, _>>()?; - let program = arguments.first().cloned().ok_or(Errno::EINVAL)?; - let mut command = Command::new(program, arguments) - .environment(environment) - .stdin(null_device(paths)?) - .stdout(request.stdout) - .stderr(request.stderr) - .working_directory(directory) - .barrier() - .enter(prepared); - if physical_pwd { - command = command.export_physical_working_directory(); - } - Ok(command) -} - /// Returns the request's working directory, or `/` without one. fn working_directory(execution: &GuestExecPayload) -> Result { c_string(execution.cwd.as_deref().unwrap_or(b"/")) } -/// Opens the null device, the workload's standard input. -fn null_device(paths: &Paths) -> Result { - sys::open( - &path_c_string(&paths.null_device)?, - OpenFlags::O_RDONLY | OpenFlags::O_CLOEXEC, - Mode::empty(), - ) -} - /// Returns the agent's environment with the workload's `HOME`, `USER`, and `LOGNAME`, and, unless /// the request replaces the environment, its `PWD`: the name of the working directory `directory` /// when it is canonical, and otherwise a placeholder whose place the directory's physical path diff --git a/nvx-rs/src/guest/managed_agent/launch/tests.rs b/nvx-rs/src/guest/managed_agent/launch/tests.rs index 7000d661a..05b42e9cc 100644 --- a/nvx-rs/src/guest/managed_agent/launch/tests.rs +++ b/nvx-rs/src/guest/managed_agent/launch/tests.rs @@ -1,22 +1,17 @@ use std::ffi::CString; -use crate::guest::spawn::LaunchStage; -use crate::guest::sys::PreparedChild; +use crate::guest::sys; use crate::protocol::GuestExecPayload; use super::*; -fn config(direct: bool) -> AgentConfig { +fn config() -> AgentConfig { AgentConfig { - rootfs: CString::new(if direct { "-" } else { "/rootfs" }).unwrap(), - hostname: c"sandbox".to_owned(), uid: c"1000".to_owned(), gid: c"1001".to_owned(), user: c"workload".to_owned(), home: c"/home/workload".to_owned(), restored_proxy: None, - direct, - entry: None, } } @@ -30,56 +25,20 @@ fn execution() -> GuestExecPayload { } } -fn descriptor() -> FileDescriptor { - FileDescriptor::from_owned(tempfile::tempfile().unwrap().into()) -} - -fn request<'a>(config: &'a AgentConfig, execution: &'a GuestExecPayload) -> LaunchRequest<'a> { - LaunchRequest { - config, - execution, - stdout: descriptor(), - stderr: descriptor(), - } -} - -/// Returns a direct agent's paths with no cgroup hierarchy. -fn direct_paths(directory: &std::path::Path) -> Paths { - Paths { - cgroup_root: directory.join("absent-cgroup"), - ..Paths::default() - } -} - /// Prepares the direct agent's plain-process child without starting it. -fn prepare_direct( - _paths: &Paths, - config: &AgentConfig, - execution: &GuestExecPayload, -) -> ExecRequest { +fn prepare_direct(config: &AgentConfig, execution: &GuestExecPayload) -> ExecRequest { direct_request(config, execution).unwrap() } -/// Prepares the child that launches `execution` with the sandbox agent's configuration. -fn prepare_sandbox(execution: &GuestExecPayload) -> PreparedChild { - let config = config(false); - let prepared = Prepared::simulated(None); - sandbox_command(&Paths::default(), request(&config, execution), prepared) - .unwrap() - .prepare() -} - fn strings(strings: &[CString]) -> Vec<&[u8]> { strings.iter().map(|string| string.to_bytes()).collect() } #[test] fn direct_launch_uses_the_plain_process_core_without_a_helper_or_cgroup() { - let temporary = tempfile::tempdir().unwrap(); - let paths = direct_paths(temporary.path()); - let config = config(true); + let config = config(); let execution = execution(); - let plan = prepare_direct(&paths, &config, &execution); + let plan = prepare_direct(&config, &execution); assert_eq!(strings(&plan.arguments), [b"/bin/echo".as_slice(), b"ok"]); assert_eq!(plan.program.as_bytes(), b"/bin/echo"); let environment = strings(&plan.environment); @@ -91,15 +50,13 @@ fn direct_launch_uses_the_plain_process_core_without_a_helper_or_cgroup() { ] { assert!(environment.contains(&expected), "{expected:?}"); } - assert!(!paths.cgroup_root.exists()); assert!(!plan.input); assert_eq!(plan.account, AccountMode::Numeric); } #[test] fn direct_launch_applies_exact_empty_and_layered_environments_in_order() { - let paths = Paths::default(); - let mut config = config(true); + let mut config = config(); config.restored_proxy = Some(Some("http://restore.example:3128".to_owned())); let exact = GuestExecPayload { environment: Some(vec![ @@ -109,7 +66,7 @@ fn direct_launch_applies_exact_empty_and_layered_environments_in_order() { ]), ..execution() }; - let plan = prepare_direct(&paths, &config, &exact); + let plan = prepare_direct(&config, &exact); assert_eq!( strings(&plan.environment), [b"EMPTY=".as_slice(), b"VALUE=last"] @@ -120,11 +77,7 @@ fn direct_launch_applies_exact_empty_and_layered_environments_in_order() { environment: Some(Vec::new()), ..execution() }; - assert!( - prepare_direct(&paths, &config, &empty) - .environment - .is_empty() - ); + assert!(prepare_direct(&config, &empty).environment.is_empty()); let layered = GuestExecPayload { environment: Some(vec![ b"HOME=/layered".to_vec(), @@ -134,7 +87,7 @@ fn direct_launch_applies_exact_empty_and_layered_environments_in_order() { inherit_default_env: true, ..execution() }; - let defaults = prepare_direct(&paths, &config, &execution()); + let defaults = prepare_direct(&config, &execution()); let mut expected = strings(&defaults.environment); let home = expected .iter() @@ -143,7 +96,7 @@ fn direct_launch_applies_exact_empty_and_layered_environments_in_order() { expected[home] = b"HOME=/layered"; expected.push(b"ADDED=2"); assert_eq!( - strings(&prepare_direct(&paths, &config, &layered).environment), + strings(&prepare_direct(&config, &layered).environment), expected ); } @@ -168,7 +121,7 @@ fn restored_proxy_defaults_replace_template_values_before_exec_overrides() { assert!(child.status().unwrap().success()); return; } - let mut config = config(true); + let mut config = config(); config.restored_proxy = Some(Some("http://restore.example:3128".to_owned())); let plan = direct_request(&config, &execution()).unwrap(); for name in names { @@ -229,7 +182,7 @@ fn privileged_direct_launch_hardens_credentials_and_resets_priority() { } sys::write_number(sys::kernel::OOM_SCORE_ADJUSTMENT, -1000).unwrap(); sys::kernel::set_nice(Some(sys::current_process()), -10).unwrap(); - let config = config(true); + let config = config(); let execution = GuestExecPayload { argv: vec![ b"/bin/sh".to_vec(), @@ -311,108 +264,6 @@ fn privileged_direct_launch_hardens_credentials_and_resets_priority() { sys::write_standard_error(format!("NVX-DIRECT-CREDENTIAL-CONTROL\n{output}").as_bytes()); } -#[test] -fn sandbox_launch_runs_the_workload_itself_through_its_entry() { - let plan = prepare_sandbox(&execution()); - assert_eq!(plan.program().as_bytes(), b"/bin/echo"); - assert_eq!(strings(plan.arguments()), [b"/bin/echo".as_slice(), b"ok"]); - let environment = strings(plan.environment()); - for expected in [ - b"HOME=/home/workload".as_slice(), - b"USER=workload", - b"LOGNAME=workload", - b"PWD=/work", - ] { - assert!(environment.contains(&expected), "{expected:?}"); - } - assert!(plan.physical_pwd().is_none()); - // The agent places the child at its barrier, so the child joins no cgroup itself. - assert!(!plan.has_cgroup()); - assert_eq!(plan.barrier_entry_and_session(), (true, true, true)); -} - -#[test] -fn sandbox_launch_applies_exact_and_layered_environments_in_order() { - let exact = GuestExecPayload { - environment: Some(vec![b"EMPTY=".to_vec(), b"VALUE=a=b".to_vec()]), - ..execution() - }; - let plan = prepare_sandbox(&exact); - assert_eq!( - strings(plan.environment()), - [b"EMPTY=".as_slice(), b"VALUE=a=b"] - ); - assert!(plan.physical_pwd().is_none()); - - let layered = GuestExecPayload { - environment: Some(vec![ - b"HOME=/layered".to_vec(), - b"ADDED=1".to_vec(), - b"ADDED=2".to_vec(), - ]), - inherit_default_env: true, - ..execution() - }; - // The request's entries layer over the defaults, wherever the agent's own environment - // put them: a name that the defaults hold keeps its place, and a new one is appended once, - // with its last value. - let defaults = prepare_sandbox(&execution()); - let mut expected = strings(defaults.environment()); - let home = expected - .iter() - .position(|entry| *entry == b"HOME=/home/workload") - .unwrap(); - expected[home] = b"HOME=/layered".as_slice(); - expected.push(b"ADDED=2"); - let plan = prepare_sandbox(&layered); - assert_eq!(strings(plan.environment()), expected); -} - -#[test] -fn sandbox_pwd_is_physical_only_for_noncanonical_names_without_an_entry() { - let noncanonical = GuestExecPayload { - cwd: Some(b"/a/../b".to_vec()), - ..execution() - }; - let plan = prepare_sandbox(&noncanonical); - let index = plan.physical_pwd_index().expect("PWD is deferred"); - assert_eq!(&plan.physical_pwd().unwrap()[..4], b"PWD="); - assert!( - strings(plan.environment()) - .iter() - .all(|entry| !entry.starts_with(b"PWD=")) - ); - assert!(index <= plan.environment().len()); - - let named = GuestExecPayload { - environment: Some(vec![b"PWD=/named".to_vec()]), - inherit_default_env: true, - ..noncanonical - }; - let plan = prepare_sandbox(&named); - assert!(plan.physical_pwd().is_none()); - assert!(strings(plan.environment()).contains(&b"PWD=/named".as_slice())); -} - -#[test] -fn sandbox_launch_reports_a_simulated_stage_without_privileges() { - let config = config(false); - let execution = execution(); - let prepared = Prepared::simulated(Some((LaunchStage::Proc, Errno::EPERM))); - let mut spawned = sandbox_command(&Paths::default(), request(&config, &execution), prepared) - .unwrap() - .spawn() - .unwrap(); - let process = spawned.process(); - spawned.take_barrier().unwrap().release().unwrap(); - let failure = spawned.into_report().wait().unwrap().unwrap(); - assert_eq!( - (failure.stage(), failure.raw_os_error()), - (LaunchStage::Proc, Errno::EPERM as i32) - ); - assert_eq!(sys::wait(process), Some(sys::ChildStatus::Exited(125))); -} - #[test] fn workload_ids_are_nonzero_decimal_and_not_the_unchanged_value() { for (text, expected) in [ @@ -447,15 +298,13 @@ fn safe_working_directory_names_match_the_c_helper() { #[test] fn direct_pwd_is_deferred_only_for_noncanonical_names() { - let temporary = tempfile::tempdir().unwrap(); - let paths = direct_paths(temporary.path()); - let config = config(true); + let config = config(); for cwd in [b"/a/./b".as_slice(), b"/a/../b", b"/a//b", b"/a/b/"] { let execution = GuestExecPayload { cwd: Some(cwd.to_vec()), ..execution() }; - let plan = prepare_direct(&paths, &config, &execution); + let plan = prepare_direct(&config, &execution); assert!(plan.physical_pwd, "PWD is deferred"); assert!( strings(&plan.environment) @@ -468,7 +317,7 @@ fn direct_pwd_is_deferred_only_for_noncanonical_names() { cwd: Some(b"/symlinked/component".to_vec()), ..execution() }; - let plan = prepare_direct(&paths, &config, &execution); + let plan = prepare_direct(&config, &execution); assert!(!plan.physical_pwd); assert!(strings(&plan.environment).contains(&b"PWD=/symlinked/component".as_slice())); } diff --git a/nvx-rs/src/guest/managed_agent/mappings.rs b/nvx-rs/src/guest/managed_agent/mappings.rs index 0fae6cbd4..c1b702eaa 100644 --- a/nvx-rs/src/guest/managed_agent/mappings.rs +++ b/nvx-rs/src/guest/managed_agent/mappings.rs @@ -1,14 +1,10 @@ -use std::ffi::OsStr; use std::io::{self, Read}; -use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::MetadataExt; -use std::path::{Path, PathBuf}; +use std::path::Path; use crate::guest::mappings::{Mapper, MappingTable, Reserved, SourceRoot, TableError, TargetRoot}; use crate::guest::sys::{self, Errno, Mode}; -use super::AgentConfig; -use super::diagnostics; use super::seam::{System, path_c_string}; /// The direct root's own mounts besides `/`'s, on which the table's mapper may create what a @@ -36,8 +32,6 @@ enum TableRoot { /// The direct agent's own root, `/`. #[default] Direct, - /// The sandbox agent's `ROOTFS`, the container's assembled root. - Sandbox(PathBuf), /// A layered root's retained mapper and export, never reopened by path. Layered, } @@ -67,22 +61,6 @@ impl HostMappings { mapper: Some(TableMapper { mapper, export }), }) } - - /// Returns the state of the table of the agent that `config` configures, before - /// [`prepare`] reads its announcement: the direct agent maps beneath `/`, and the sandbox - /// agent beneath `ROOTFS`. - pub(crate) fn new(config: &AgentConfig) -> Self { - let root = if config.direct { - TableRoot::Direct - } else { - let rootfs = OsStr::from_bytes(config.rootfs.as_bytes()); - TableRoot::Sandbox(PathBuf::from(rootfs)) - }; - Self { - root, - ..Self::default() - } - } } /// Failure returned while validating or applying one MAPS request. @@ -99,9 +77,8 @@ pub(crate) enum MappingError { }, } -/// Reads the announced mapping count and validates the host export mount, in either mode: the -/// guest's init mounts the export for the direct agent, and the init agent for the sandbox -/// agent, at the same place. +/// Reads the announced mapping count and validates the host export mount, which the guest's init +/// mounts for the direct agent. pub(crate) fn prepare(system: &mut impl System, mappings: &mut HostMappings) -> Result<(), Errno> { let mut command_line = [0u8; 4096]; let count = std::fs::File::open(&system.paths().proc_cmdline) @@ -135,10 +112,8 @@ pub(crate) fn prepare(system: &mut impl System, mappings: &mut HostMappings) -> /// latches, with the table, at the first entry that fails, as the /// [mappings](crate::guest::mappings) module describes. The direct agent's table root is its own, /// `/`, on whose mount and the tmpfs at `/tmp`, which a mapping may also cover, the mapper creates -/// what a target lacks. The sandbox agent's is `ROOTFS`, the container's assembled root, on whose -/// mount alone it creates what a target lacks, and whose runtime's targets it refuses: -/// [`Reserved::container_root`]. Either creates nothing inside another mapping. The first part -/// that the table hands it opens them, so that a table of zero mappings does no work. +/// what a target lacks, and nothing inside another mapping. The first part that the table hands +/// it opens them, so that a table of zero mappings does no work. pub(crate) fn apply_request( system: &mut impl System, mappings: &mut HostMappings, @@ -187,10 +162,6 @@ fn open_table(system: &mut impl System, root: &TableRoot) -> Result ( - TargetRoot::open_with(system.kernel(), rootfs).map_err(errno)?, - Reserved::container_root(), - ), TableRoot::Layered => return Err(Errno::EINVAL), }; let export = SourceRoot::open(&system.paths().hostfs_dir.join("root")).map_err(errno)?; @@ -199,27 +170,5 @@ fn open_table(system: &mut impl System, root: &TableRoot) -> Result Result<(), Errno> { - let Some(mut opened) = mappings.mapper.take() else { - return Ok(()); - }; - let failures = opened.mapper.unmount_with(system.kernel()); - let mut first = None; - for (entry, failure) in failures { - let status = failure.error().raw_os_error(); - // The table holds at most `MAX_HOST_MAPPINGS` entries, whose indices fit in a `u32`. - diagnostics::unmapping_error(system, status, entry as u32); - first.get_or_insert(status); - } - match first { - Some(status) => Err(Errno::from_raw(status)), - None => Ok(()), - } -} #[cfg(test)] mod tests; diff --git a/nvx-rs/src/guest/managed_agent/mappings/tests.rs b/nvx-rs/src/guest/managed_agent/mappings/tests.rs index 913d194cb..803eaa4b5 100644 --- a/nvx-rs/src/guest/managed_agent/mappings/tests.rs +++ b/nvx-rs/src/guest/managed_agent/mappings/tests.rs @@ -1,6 +1,7 @@ use std::fs; use std::os::unix::ffi::OsStrExt; use std::os::unix::fs::PermissionsExt; +use std::path::PathBuf; use super::super::seam::tests::{TestSystem, attach_calls}; use super::*; @@ -17,15 +18,6 @@ impl HostMappings { ..Self::default() } } - - /// Returns the state of the table of `expected` announced mappings of the agent that - /// `config` configures, none applied yet. - pub(crate) fn announced_for(config: &AgentConfig, expected: u32) -> Self { - Self { - table: announced_table(expected), - ..Self::new(config) - } - } } /// Returns the table of `expected` announced mappings of a test, at most as many as a table @@ -53,89 +45,12 @@ fn exported(system: &TestSystem, relative: &str) -> PathBuf { .join(relative) } -/// Returns a system with a host export, the state of a sandbox agent's table of `expected` -/// mappings, and the canonical path of the agent's assembled root, which is the root of a -/// mount. -fn sandbox(expected: u32) -> (tempfile::TempDir, TestSystem, HostMappings, PathBuf) { - let (temporary, system) = TestSystem::with_host_export(); +#[test] +fn a_layered_table_that_announces_mappings_needs_its_retained_export() { + let (temporary, mut system) = TestSystem::with_host_export(); let rootfs = temporary.path().canonicalize().unwrap().join("rootfs"); fs::create_dir(&rootfs).unwrap(); system.kernel.add_mount(&rootfs); - let config = AgentConfig::for_test(Some(&rootfs)); - let mappings = HostMappings::announced_for(&config, expected); - (temporary, system, mappings, rootfs) -} - -/// Applies three mappings of a sandbox's table: a read-only directory at `/workspace`, a file -/// at `/data/notes.txt`, and a directory at `/out`, whose paths below `rootfs` it returns. -fn map_three(system: &mut TestSystem, mappings: &mut HostMappings, rootfs: &Path) -> [PathBuf; 3] { - let request = payload( - 0, - &[ - (b"0/src", b"/workspace", protocol::MAP_READ_ONLY), - (b"0/notes.txt", b"/data/notes.txt", 0), - (b"1", b"/out", 0), - ], - None, - ); - apply_request(system, mappings, &request).unwrap(); - ["workspace", "data/notes.txt", "out"].map(|target| rootfs.join(target)) -} - -#[test] -fn a_sandbox_table_unmounts_in_reverse_order() { - let (_temporary, mut system, mut mappings, rootfs) = sandbox(3); - let [workspace, notes, out] = map_three(&mut system, &mut mappings, &rootfs); - assert_eq!(mappings.table.applied(), 3); - teardown(&mut system, &mut mappings).unwrap(); - let shown = |path: &PathBuf| path.display().to_string(); - assert_eq!( - system.kernel.unmounts(), - [&out, ¬es, &workspace].map(shown) - ); - for target in [&workspace, ¬es, &out] { - assert_eq!(system.kernel.mount_at(target), None); - } - // The teardown forgot the table, so another has nothing to do. - teardown(&mut system, &mut mappings).unwrap(); - assert_eq!(system.kernel.unmounts().len(), 3); - assert!(system.diagnostics.is_empty()); -} - -#[test] -fn a_sandbox_table_reports_each_mapping_that_stays_mounted() { - let (_temporary, mut system, mut mappings, rootfs) = sandbox(3); - let [workspace, notes, out] = map_three(&mut system, &mut mappings, &rootfs); - system.kernel.fail(Operation::Unmount, ¬es, Errno::EBUSY); - assert_eq!(teardown(&mut system, &mut mappings), Err(Errno::EBUSY)); - // Every mapping is attempted, in reverse order, and only the busy one stays mounted. - let shown = |path: &PathBuf| path.display().to_string(); - assert_eq!( - system.kernel.unmounts(), - [&out, ¬es, &workspace].map(shown) - ); - assert!(system.kernel.mount_at(¬es).is_some()); - assert_eq!(system.kernel.mount_at(&workspace), None); - assert_eq!(system.kernel.mount_at(&out), None); - assert_eq!( - String::from_utf8_lossy(&system.diagnostics), - format!( - "NVX-MANAGED-ERROR: stage=host-unmapping status={}\n", - Errno::EBUSY as i32 - ) - ); -} - -#[test] -fn a_table_without_mappings_has_nothing_to_tear_down() { - let (_temporary, mut system, mut mappings, _rootfs) = sandbox(0); - teardown(&mut system, &mut mappings).unwrap(); - assert!(system.kernel.calls().is_empty()); -} - -#[test] -fn a_layered_table_that_announces_mappings_needs_its_retained_export() { - let (_temporary, mut system, _mappings, rootfs) = sandbox(1); let mapper = Mapper::new( TargetRoot::open_with(&mut system.kernel, &rootfs).unwrap(), Reserved::container_root(), @@ -383,15 +298,6 @@ fn the_root_of_the_export_is_refused_though_it_decodes() { (0, false) ); assert!(!first.exists() && !root.exists()); - // So does a sandbox agent, whose export is an aggregate too. - let (_temporary, mut system, mut mappings, rootfs) = sandbox(1); - let request = payload(0, &[(b"", b"/workspace", 0)], None); - assert!(matches!( - apply_request(&mut system, &mut mappings, &request), - Err(MappingError::Invalid) - )); - assert!(system.kernel.calls().is_empty()); - assert!(!rootfs.join("workspace").exists()); } #[test] diff --git a/nvx-rs/src/guest/managed_agent/mod.rs b/nvx-rs/src/guest/managed_agent/mod.rs index d0762a20f..f27f53ec8 100644 --- a/nvx-rs/src/guest/managed_agent/mod.rs +++ b/nvx-rs/src/guest/managed_agent/mod.rs @@ -4,68 +4,52 @@ //! //! With `ROOTFS` `-`, the direct agent runs as PID 1: it applies the host mapping table beneath //! its own root, `/`, launches each workload through nvx-init's plain-process core, reaps orphaned -//! processes, and powers the VM off after `STOP`. Otherwise the sandbox agent applies the host -//! mapping table beneath `ROOTFS`, the container's assembled root, has each workload enter -//! `ROOTFS` as an [`entry`](crate::guest::entry) execution, as `UID` and `GID` with their existing -//! account, with `HOSTNAME`, held at a barrier until the agent has placed it in its cgroup, and -//! after `STOP` unmounts the table and returns to the init agent. When it starts, it generates the -//! workload's machine ID and binds it, with the guest's time status, into `ROOTFS` in a mount -//! namespace of its own, of which each execution's starts as a copy, so that the binds end with -//! the agent, and it maps the table in that namespace too. Both refuse workloads until the table -//! is complete, and both keep the C agent's command lines, wire behavior, status and error -//! categories, 1 MiB output and receive windows, and timeout and cancellation behavior. +//! processes, and powers the VM off after `STOP`. It refuses any other `ROOTFS`. It refuses +//! workloads until the table is complete, and keeps the C agent's command lines, wire behavior, +//! status and error categories, 1 MiB output and receive windows, and timeout and cancellation +//! behavior. //! //! The agent exits with 0, or with 125 when it fails. //! //! # Structure //! //! The submodules divide the agent by concern. Mounts, ownership and mode changes, the diagnostic -//! port, TTY operations, VM shutdown, the workload launch, the -//! preparation of each sandbox execution, the kernel of the host mapping table's +//! port, TTY operations, VM shutdown, the workload launch, the kernel of the host mapping table's //! [mapper](crate::guest::mappings), and the production paths are behind a system seam. //! Production implements it with the safe `sys` wrappers; tests substitute temporary directories //! and recording operations, including failures of mount, environment export, and `exec`, the //! root operations' fake kernel for the mapper, and spawn workloads through the real launch with -//! its credential calls replaced, or with the stages of the sandbox -//! agent's entry simulated. The agent keeps the table's account in a +//! its credential calls replaced. The agent keeps the table's account in a //! [`MappingTable`](crate::guest::mappings::MappingTable), as any guest agent may, which checks //! each part that the host sends and hands its mappings to the agent's mapper. //! //! The direct agent uses [`init`](crate::guest::init)'s child builder and execution driver. -//! The sandbox agent keeps its entry, barrier, and [`exec::Execution`](crate::guest::exec::Execution). -//! Both build complete `argv` and `envp` before the child starts; the child performs no allocation +//! It builds complete `argv` and `envp` before the child starts; the child performs no allocation //! or Rust cleanup before `execve` or `_exit`. The execution reads the workload's output, //! ends it, and reports its launch and outcome, and the //! agent forwards the output as NVXC frames, serves the control session while the workload runs, //! and reports the outcome. The agent keeps its own policy: the null device as the workload's //! standard input, the identity, the output cap, //! the timeout, the precedence of a cancellation, a timeout, and the cap over the workload's own -//! status, and the outcome that it reports for a failed launch stage. Both build the workload's -//! environment in memory, as C's environment functions changed it, and pass it to `execve`, so +//! status, and the outcome that it reports for a failed launch stage. It builds the workload's +//! environment in memory, as C's environment functions changed it, and passes it to `execve`, so //! that no process changes its //! environment. //! //! The agent is its process's one status collector, as the direct agent must be as PID 1: its own //! `waitpid(-1)` loop collects every child's status and hands the workload's to the workload's //! execution. The direct core kills each workload's process group, including on normal completion. -//! The sandbox agent places each workload in the `container` -//! cgroup that the init agent prepared, and ends only the workload, whose end ends every process -//! of its PID namespace, and its process group. //! //! Linux arguments, paths, and environment strings are bytes, not necessarily UTF-8, so the //! protocol's guest `EXEC` and `MAPS` decoders accept what the C guest accepts. The boot contract //! is one `nvx_maps=COUNT` token from 1 through 4096, and retired `nvx_map=` tokens are ignored. use std::ffi::CString; -use std::os::unix::ffi::OsStrExt; use std::process::ExitCode; -use super::entry::{Entry, Identity, MachineId}; use super::status::{CommandError, ExitStatus}; use super::sys::{self, Errno}; -/// Legacy sandbox workload placement. -mod cgroup; /// C-compatible diagnostics. mod diagnostics; /// Long-lived control request dispatcher. @@ -75,7 +59,7 @@ mod environment; /// The NVXC adapter, which runs each workload as an execution, and its output cap, cancellation, /// and timeout. mod exec; -/// Direct and sandbox launch commands. +/// Direct launch commands. mod launch; /// Ordered host-filesystem mapping application. mod mappings; @@ -93,10 +77,6 @@ use session::Session; /// Immutable command-line configuration of a long-lived agent. pub(crate) struct AgentConfig { - /// Sandbox root filesystem, or `-` in direct mode. - rootfs: CString, - /// Sandbox hostname. - hostname: CString, /// Decimal workload user ID. uid: CString, /// Decimal workload group ID. @@ -107,10 +87,6 @@ pub(crate) struct AgentConfig { home: CString, /// An accepted restore's proxy; outer `None` preserves cold/legacy defaults. restored_proxy: Option>, - /// Whether this process is the direct PID-1 agent. - direct: bool, - /// The entry of the sandbox agent's executions, which the direct agent has none of. - entry: Option, } /// Runs `nvx-managed-agent` with the process's arguments. It exits with 0, or 125 when the agent @@ -123,48 +99,18 @@ pub fn main() -> ExitCode { /// Runs the long-lived agent. fn run(system: &mut impl System, arguments: &[CString]) -> Result<(), CommandError> { - if arguments.len() != 8 { + if arguments.len() != 8 || arguments[2].as_bytes() != b"-" { return Err(CommandError::Agent); } - let mut config = AgentConfig { - rootfs: arguments[2].clone(), - hostname: arguments[3].clone(), + let config = AgentConfig { uid: arguments[4].clone(), gid: arguments[5].clone(), user: arguments[6].clone(), home: arguments[7].clone(), restored_proxy: None, - direct: arguments[2].as_bytes() == b"-", - entry: None, }; - if !config.direct { - let entry = match sandbox_entry(system.paths(), &config) { - Ok(entry) => entry, - Err(error) => return Err(fatal(system, "workload-entry", error)), - }; - if let Err(error) = system.bind_runtime(&entry) { - return Err(fatal(system, "workload-entry", error)); - } - config.entry = Some(entry); - } let result = serve(system, &arguments[1], &config); - finish(system, &config, result) -} - -/// Returns the entry of the sandbox agent's executions: `ROOTFS`, entered as `UID` and `GID` -/// with their existing account, with `HOSTNAME`, the machine ID that it generates for this boot -/// at its path, and the guest's time status. -fn sandbox_entry(paths: &Paths, config: &AgentConfig) -> Result { - let uid = launch::parse_id(config.uid.as_bytes())?; - let gid = launch::parse_id(config.gid.as_bytes())?; - let rootfs = std::ffi::OsStr::from_bytes(config.rootfs.as_bytes()); - let entry = Entry::new(rootfs, Identity::new(uid, gid)) - .hostname(config.hostname.as_bytes()) - .time_status(&paths.time_status); - entry.check().map_err(|_| Errno::EINVAL)?; - let machine_id = - MachineId::generate(&paths.machine_id).map_err(|error| sys::errno_of(&error))?; - Ok(entry.machine_id(machine_id)) + finish(system, result) } /// Serves the control session on `control_tty` until the host stops the agent. @@ -177,7 +123,7 @@ fn serve( Ok(fd) => fd, Err(error) => return Err(fatal(system, "control-open", error)), }; - let mut mappings = HostMappings::new(config); + let mut mappings = HostMappings::default(); if let Err(error) = mappings::prepare(system, &mut mappings) { return Err(fatal(system, "host-mappings", error)); } @@ -185,17 +131,9 @@ fn serve( return Err(fatal(system, "control-tty", error)); } let mut session = Session::new(fd); - let served = dispatch::run(system, &mut session, config, &mut mappings); - // The sandbox agent unmounts its mappings before it returns to the init agent, which then - // unmounts the root, and the direct agent powers the VM off with its mappings in place. - let unmapped = if config.direct { - Ok(()) - } else { - mappings::teardown(system, &mut mappings) - }; - match served { - // The teardown reported each mapping that stays mounted. - Ok(dispatch::Outcome::Stopped) => unmapped.map_err(|_| CommandError::Agent), + // The agent powers the VM off with its mappings in place. + match dispatch::run(system, &mut session, config, &mut mappings) { + Ok(dispatch::Outcome::Stopped) => Ok(()), Err(error) => Err(fatal(system, "control-session", error)), } } @@ -209,15 +147,8 @@ fn fatal(system: &mut impl System, stage: &str, error: Errno) -> CommandError { CommandError::Agent } -/// Powers off a direct-mode VM with the agent's exit status, and returns the agent's result. -fn finish( - system: &mut impl System, - config: &AgentConfig, - result: Result<(), CommandError>, -) -> Result<(), CommandError> { - if !config.direct { - return result; - } +/// Powers off the VM with the agent's exit status, and returns the agent's result. +fn finish(system: &mut impl System, result: Result<(), CommandError>) -> Result<(), CommandError> { let Ok(path) = path_c_string(&system.paths().exit) else { return result; }; diff --git a/nvx-rs/src/guest/managed_agent/seam.rs b/nvx-rs/src/guest/managed_agent/seam.rs index 9ce1f61b2..4839b86f4 100644 --- a/nvx-rs/src/guest/managed_agent/seam.rs +++ b/nvx-rs/src/guest/managed_agent/seam.rs @@ -1,29 +1,19 @@ use std::ffi::{CStr, CString}; use std::path::{Path, PathBuf}; -use crate::guest::entry::{self, Entry, Prepared}; use crate::guest::init; use crate::guest::mappings::{Kernel as MappingKernel, SystemKernel}; -use crate::guest::spawn::{Command, Spawned}; use crate::guest::sys::{self, Errno, FileDescriptor, Mode, OpenFlags}; /// Production paths used by the managed agent and replaceable paths used by tests. #[derive(Clone, Debug)] pub(crate) struct Paths { - /// Unified cgroup-v2 root of the legacy sandbox agent and its workloads. - pub(crate) cgroup_root: PathBuf, /// Parent of OpenVMM's aggregate host-filesystem exports. pub(crate) hostfs_dir: PathBuf, /// Kernel command line containing the announced mapping count. pub(crate) proc_cmdline: PathBuf, /// Port device used for fatal diagnostics. pub(crate) diagnostic_port: PathBuf, - /// Null device used for child standard input. - pub(crate) null_device: PathBuf, - /// The machine ID that the sandbox agent generates once, and binds for its executions. - pub(crate) machine_id: PathBuf, - /// The guest's time status, which the sandbox agent binds for its executions. - pub(crate) time_status: PathBuf, /// VM exit helper. pub(crate) exit: PathBuf, } @@ -32,13 +22,9 @@ impl Default for Paths { /// Returns the paths installed in production guest images. fn default() -> Self { Self { - cgroup_root: "/sys/fs/cgroup".into(), hostfs_dir: "/run/nvx/hostfs".into(), proc_cmdline: "/proc/cmdline".into(), diagnostic_port: "/dev/port".into(), - null_device: "/dev/null".into(), - machine_id: "/run/nvx/workload-machine-id".into(), - time_status: "/run/nvx/time".into(), exit: "/sbin/nvx-exit".into(), } } @@ -75,8 +61,6 @@ pub(crate) trait System { arguments: &[CString], environment: Option<&[CString]>, ) -> Result<(), Errno>; - /// Spawns one workload's launch command. - fn spawn(&mut self, command: Command) -> Result; /// Starts a direct workload through init's native plain-process core. fn start_direct( &mut self, @@ -84,12 +68,6 @@ pub(crate) trait System { ) -> Result { init::Exec::start(request) } - /// Prepares one execution of the sandbox agent's `entry`. - fn prepare_entry(&mut self, entry: &Entry) -> Result; - /// Moves the sandbox agent into a mount namespace of its own, and binds `entry`'s runtime - /// files into its root there, once, for every execution, whose namespace starts as a copy of - /// the agent's. The binds end with the agent, before the init agent unmounts the root. - fn bind_runtime(&mut self, entry: &Entry) -> Result<(), Errno>; } /// Production implementation backed exclusively by safe `guest::sys` APIs. @@ -157,36 +135,6 @@ impl System for RealSystem { ) -> Result<(), Errno> { Err(sys::exec(path, arguments, environment)) } - - fn spawn(&mut self, command: Command) -> Result { - command.spawn().map_err(|error| sys::errno_of(&error)) - } - - fn prepare_entry(&mut self, entry: &Entry) -> Result { - entry.prepare() - } - - fn bind_runtime(&mut self, entry: &Entry) -> Result<(), Errno> { - sys::enter_private_mount_namespace()?; - // The binds end with the agent's mount namespace, before the init agent unmounts the - // root, so the agent leaves them mounted. - entry - .bind_runtime() - .map(drop) - .map_err(|error| runtime_errno(&error)) - } -} - -/// Returns the errno of a failure of [`Entry::bind_runtime`], that of the bind that failed. -fn runtime_errno(error: &entry::Error) -> Errno { - match error { - entry::Error::Root(error) => sys::errno_of(error), - entry::Error::MachineId(error) - | entry::Error::TimeStatus(error) - | entry::Error::Resolver(error) => Errno::from_raw(error.raw_os_error()), - entry::Error::Rollback { error, .. } => runtime_errno(error), - _ => Errno::EINVAL, - } } /// Converts a path through the system layer for use by the seam and callers. diff --git a/nvx-rs/src/guest/managed_agent/seam/tests.rs b/nvx-rs/src/guest/managed_agent/seam/tests.rs index 574203b6d..87fbb0eea 100644 --- a/nvx-rs/src/guest/managed_agent/seam/tests.rs +++ b/nvx-rs/src/guest/managed_agent/seam/tests.rs @@ -14,23 +14,10 @@ pub(crate) struct TestSystem { pub(crate) fail_environment: Option>, pub(crate) execs: Vec, pub(crate) spawn_calls: usize, - /// Spawns each workload's launch command when set; `spawn` otherwise fails with `ENOSYS`. - pub(crate) spawner: Option Result>, pub(crate) direct_starter: Option Result>, pub(crate) tty_configured: usize, pub(crate) tty_drained: usize, - /// The sandbox executions that `prepare_entry` prepared, each simulated, without - /// privileges: see [`Prepared::simulated`]. - pub(crate) entries_prepared: usize, - /// The entry stage that fails, with its error, in each simulated sandbox execution. - pub(crate) entry_failure: Option<(crate::guest::spawn::LaunchStage, Errno)>, - /// Makes `prepare_entry` refuse each sandbox execution, as when its account is missing. - pub(crate) refuse_entry: bool, - /// The calls of `bind_runtime`, which binds nothing. - pub(crate) runtime_binds: usize, - /// The error with which `bind_runtime` fails, if any. - pub(crate) fail_runtime_bind: Option, } impl TestSystem { @@ -42,46 +29,16 @@ impl TestSystem { fail_environment: None, execs: Vec::new(), spawn_calls: 0, - spawner: None, direct_starter: None, tty_configured: 0, tty_drained: 0, - entries_prepared: 0, - entry_failure: None, - refuse_entry: false, - runtime_binds: 0, - fail_runtime_bind: None, } } - /// Returns a direct agent with no cgroup hierarchy in its temporary directory. + /// Returns a direct agent's system, and a new temporary directory that the caller keeps. #[cfg(feature = "cli")] pub(crate) fn with_direct() -> (tempfile::TempDir, Self) { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().join("cgroup"); - let paths = Paths { - cgroup_root: root, - ..Paths::default() - }; - (temporary, Self::new(paths)) - } - - /// Returns a system for the sandbox agent, in a new temporary directory that the caller - /// keeps: its cgroup root holds the agent's `agent` cgroup and its workloads' `container` - /// cgroup, with an empty membership file, and its machine ID's path is free. - pub(crate) fn with_container_cgroup() -> (tempfile::TempDir, Self) { - let temporary = tempfile::tempdir().unwrap(); - let root = temporary.path().join("cgroup"); - std::fs::create_dir_all(root.join("agent")).unwrap(); - std::fs::create_dir_all(root.join("container")).unwrap(); - std::fs::write(root.join("container/cgroup.procs"), b"").unwrap(); - let paths = Paths { - cgroup_root: root, - machine_id: temporary.path().join("workload-machine-id"), - time_status: temporary.path().join("time"), - ..Paths::default() - }; - (temporary, Self::new(paths)) + (tempfile::tempdir().unwrap(), Self::new(Paths::default())) } /// Returns a system whose host-filesystem export, in a new temporary directory that the @@ -165,14 +122,6 @@ impl System for TestSystem { Ok(()) } - fn spawn(&mut self, command: Command) -> Result { - self.spawn_calls += 1; - match self.spawner { - Some(spawner) => spawner(command), - None => Err(Errno::ENOSYS), - } - } - fn start_direct( &mut self, request: init::ExecRequest, @@ -183,19 +132,6 @@ impl System for TestSystem { None => Err(Errno::ENOSYS.into()), } } - - fn prepare_entry(&mut self, _entry: &Entry) -> Result { - self.entries_prepared += 1; - if self.refuse_entry { - return Err(entry::Error::Account(entry::AccountError::Missing)); - } - Ok(Prepared::simulated(self.entry_failure)) - } - - fn bind_runtime(&mut self, _entry: &Entry) -> Result<(), Errno> { - self.runtime_binds += 1; - self.fail_runtime_bind.map_or(Ok(()), Err) - } } /// Starts a native direct workload as the privileged agent does, without privileges: the child's diff --git a/nvx-rs/src/guest/managed_agent/tests.rs b/nvx-rs/src/guest/managed_agent/tests.rs index 7f1bae486..dcb374ebd 100644 --- a/nvx-rs/src/guest/managed_agent/tests.rs +++ b/nvx-rs/src/guest/managed_agent/tests.rs @@ -1,6 +1,5 @@ use super::seam::tests::TestSystem; use super::*; -use crate::guest::entry::AccountMode; pub(crate) use crate::guest::sys::tests::isolate_process_test; #[cfg(feature = "cli")] @@ -13,46 +12,19 @@ pub(crate) static PROCESS_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::ne impl AgentConfig { /// Returns the configuration of a test agent of the workload `1000:1000`, named `workload` - /// and homed in `/home/workload`: a direct agent, or a sandbox agent whose assembled root is - /// `rootfs`. - pub(crate) fn for_test(rootfs: Option<&std::path::Path>) -> Self { - let rootfs = rootfs.map_or(c"-".to_owned(), |rootfs| { - CString::new(rootfs.as_os_str().as_bytes()).expect("a test path has no NUL") - }); - let direct = rootfs.as_bytes() == b"-"; - // The test system prepares each sandbox execution itself. - let entry = (!direct).then(|| { - let path = std::ffi::OsStr::from_bytes(rootfs.as_bytes()); - Entry::new(path, Identity::new(1000, 1000)) - }); + /// and homed in `/home/workload`. + #[cfg(feature = "cli")] + pub(crate) fn for_test() -> Self { Self { - direct, - rootfs, - hostname: c"test".to_owned(), uid: c"1000".to_owned(), gid: c"1000".to_owned(), user: c"workload".to_owned(), home: c"/home/workload".to_owned(), restored_proxy: None, - entry, } } } -fn sandbox_config(hostname: &[u8], uid: &[u8]) -> AgentConfig { - AgentConfig { - rootfs: c"/run/nvx/rootfs".to_owned(), - hostname: CString::new(hostname).unwrap(), - uid: CString::new(uid).unwrap(), - gid: c"65534".to_owned(), - user: c"nobody".to_owned(), - home: c"/nonexistent".to_owned(), - restored_proxy: None, - direct: false, - entry: None, - } -} - #[test] fn a_layered_cold_boot_takes_only_the_clis_workload_identity() { for command_line in [ @@ -69,8 +41,6 @@ fn a_layered_cold_boot_takes_only_the_clis_workload_identity() { assert_eq!(config.gid.as_bytes(), b"1001"); assert_eq!(config.user.as_c_str(), c"workload"); assert_eq!(config.home.as_c_str(), c"/home/workload"); - assert!(config.direct); - assert!(config.entry.is_none()); } } @@ -101,73 +71,8 @@ fn a_layered_cold_boot_refuses_missing_repeated_or_invalid_cli_identities() { } #[test] -fn the_sandbox_entry_takes_an_existing_account_and_one_machine_id_per_boot() { - let (_temporary, system) = TestSystem::with_container_cgroup(); - let config = sandbox_config(b"sandbox", b"65534"); - let entry = sandbox_entry(system.paths(), &config).unwrap(); - let identity = entry.identity(); - assert_eq!((identity.uid(), identity.gid()), (65534, 65534)); - assert_eq!(identity.account_mode(), AccountMode::Existing); - assert!(identity.capability_set().is_empty()); - assert!(identity.supplementary_groups().is_empty()); - let machine_id = std::fs::read(&system.paths.machine_id).unwrap(); - assert_eq!(machine_id.len(), 33); - assert!( - machine_id[..32] - .iter() - .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(byte)) - ); - // Another agent of the same boot would find the machine ID taken. - assert_eq!( - sandbox_entry(system.paths(), &config).err(), - Some(Errno::EEXIST) - ); - assert_eq!(std::fs::read(&system.paths.machine_id).unwrap(), machine_id); -} - -#[test] -fn a_refused_sandbox_entry_generates_nothing_and_stops_the_agent() { - let (_temporary, mut system) = TestSystem::with_container_cgroup(); - for (hostname, uid) in [ - (&[b'h'; 65][..], b"65534".as_slice()), - (b"", b"65534"), - (b"sandbox", b"0"), - (b"sandbox", b"4294967295"), - ] { - assert_eq!( - sandbox_entry(system.paths(), &sandbox_config(hostname, uid)).err(), - Some(Errno::EINVAL) - ); - assert!(!system.paths.machine_id.exists()); - } - let arguments = [ - "nvx-managed-agent", - "/dev/null", - "/run/nvx/rootfs", - "sandbox", - "0", - "65534", - "nobody", - "/nonexistent", - ] - .map(|argument| CString::new(argument).unwrap()); - assert_eq!(run(&mut system, &arguments), Err(CommandError::Agent)); - assert_eq!( - system.diagnostics, - format!( - "NVX-MANAGED-ERROR: stage=workload-entry status={}\n", - Errno::EINVAL as i32 - ) - .as_bytes() - ); - assert_eq!(system.tty_configured, 0); - assert_eq!(system.runtime_binds, 0); -} - -#[test] -fn a_failed_runtime_bind_stops_the_sandbox_agent_before_its_session() { - let (_temporary, mut system) = TestSystem::with_container_cgroup(); - system.fail_runtime_bind = Some(Errno::EXDEV); +fn the_agent_refuses_any_root_but_its_own() { + let mut system = TestSystem::new(Paths::default()); let arguments = [ "nvx-managed-agent", "/dev/null", @@ -180,15 +85,7 @@ fn a_failed_runtime_bind_stops_the_sandbox_agent_before_its_session() { ] .map(|argument| CString::new(argument).unwrap()); assert_eq!(run(&mut system, &arguments), Err(CommandError::Agent)); - assert_eq!(system.runtime_binds, 1); - assert!(system.paths.machine_id.exists()); - assert_eq!( - system.diagnostics, - format!( - "NVX-MANAGED-ERROR: stage=workload-entry status={}\n", - Errno::EXDEV as i32 - ) - .as_bytes() - ); + assert!(system.diagnostics.is_empty()); assert_eq!(system.tty_configured, 0); + assert!(system.execs.is_empty()); } diff --git a/nvx-rs/src/guest/managed_agent/tests/loopback/tests.rs b/nvx-rs/src/guest/managed_agent/tests/loopback/tests.rs index 2d562cff6..28570ecbc 100644 --- a/nvx-rs/src/guest/managed_agent/tests/loopback/tests.rs +++ b/nvx-rs/src/guest/managed_agent/tests/loopback/tests.rs @@ -54,15 +54,11 @@ fn control_session_broker_and_real_agent_loop_back() { let agent_thread = thread::spawn(move || { let mut system = system; let config = AgentConfig { - rootfs: c"-".to_owned(), - hostname: c"loopback".to_owned(), uid: c"1000".to_owned(), gid: c"1000".to_owned(), user: c"workload".to_owned(), home: c"/".to_owned(), restored_proxy: None, - direct: true, - entry: None, }; let mut session = Session::new(agent_descriptor); let mut mappings = HostMappings::announced(1); @@ -198,15 +194,11 @@ fn output_limit_forwards_nothing_after_the_cap() { let agent_thread = thread::spawn(move || { let mut system = system; let config = AgentConfig { - rootfs: c"-".to_owned(), - hostname: c"loopback".to_owned(), uid: c"1000".to_owned(), gid: c"1000".to_owned(), user: c"workload".to_owned(), home: c"/".to_owned(), restored_proxy: None, - direct: true, - entry: None, }; let mut session = Session::new(agent_descriptor); let mut mappings = HostMappings::announced(1); diff --git a/nvx-rs/src/guest/mappings.rs b/nvx-rs/src/guest/mappings.rs index d026ed90b..732b57628 100644 --- a/nvx-rs/src/guest/mappings.rs +++ b/nvx-rs/src/guest/mappings.rs @@ -24,9 +24,8 @@ //! the mappings until its teardown. //! //! NVX's managed agent applies its host mapping table through one, and through a mapper beneath -//! its own root, `/`, in direct mode, or beneath the container's assembled root in sandbox mode, -//! from its mount of the host's export, an aggregate, whose root, which lists the aggregate's -//! children, no table maps: a table refuses an empty source. +//! its own root, `/`, in direct mode, from its mount of the host's export, an aggregate, whose +//! root, which lists the aggregate's children, no table maps: a table refuses an empty source. mod kernel; #[doc(hidden)] diff --git a/nvx-rs/src/guest/mappings/table.rs b/nvx-rs/src/guest/mappings/table.rs index 6a873217a..0bb234d81 100644 --- a/nvx-rs/src/guest/mappings/table.rs +++ b/nvx-rs/src/guest/mappings/table.rs @@ -41,8 +41,8 @@ use crate::protocol::{self, GuestHostMap, MAX_HOST_MAPPINGS, ProtocolError}; /// as a mapper's teardown, which reports each mapping that stays mounted, or a layered root's. /// /// NVX's managed agent applies its table through one, beneath its own root, `/`, in direct mode, -/// and beneath the container's assembled root in sandbox mode, with a mapper that it opens at the -/// first part that the table hands it, so that a table of zero mappings opens nothing. +/// with a mapper that it opens at the first part that the table hands it, so that a table of zero +/// mappings opens nothing. /// /// ```no_run /// use nvx::guest::mappings::{Mapping, MappingTable, TableError}; diff --git a/nvx-rs/src/guest/mod.rs b/nvx-rs/src/guest/mod.rs index 85c92fba1..2fef49f0c 100644 --- a/nvx-rs/src/guest/mod.rs +++ b/nvx-rs/src/guest/mod.rs @@ -59,8 +59,8 @@ //! activation reports the publication and gateway to its caller, which runs the program, or has //! `nvx-ingress start` run it from a socket that it binds for it. The //! libraries need none of NVX's control protocol, managed agent, or workload identity, and NVX's -//! managed agent opens its control terminal, applies its host mapping table, and supervises its -//! workloads through them. On x86-64, the hidden `init` module holds +//! managed agent opens its control terminal and applies its host mapping table through them. +//! On x86-64, the hidden `init` module holds //! `nvx-init`, a PID 1 for layered roots, which takes PID 1 over from their early boot with the //! guest's helpers, the time daemon and the announcer: it runs with them ahead of the workloads, //! collects every child's status, and ends the VM after `SIGTERM` or once either helper ends. diff --git a/nvx-rs/src/guest/process.rs b/nvx-rs/src/guest/process.rs index c21d15d32..26327ebfc 100644 --- a/nvx-rs/src/guest/process.rs +++ b/nvx-rs/src/guest/process.rs @@ -135,15 +135,6 @@ impl Child { }) } - /// Names the crate's own child, for the callers that hold its [`ProcessId`]. - pub(crate) fn from_process(process: ProcessId) -> Self { - Self { - process, - pidfd: None, - status: None, - } - } - /// Opens a pidfd for the child by its ID, unless the child has one already or has ended: the /// ID names the child alone only while its status stays uncollected, which the caller /// guarantees until this returns. diff --git a/nvx-rs/src/guest/spawn.rs b/nvx-rs/src/guest/spawn.rs index 7a1601303..e55bd0cbf 100644 --- a/nvx-rs/src/guest/spawn.rs +++ b/nvx-rs/src/guest/spawn.rs @@ -31,7 +31,7 @@ //! //! No step needs NVX's control protocol, its managed agent and helper, or its workload identity: //! the caller chooses the program, and the identity, if any, that an entry gives it, or as which -//! the child checks its working directory. NVX's managed agent is one caller. +//! the child checks its working directory. //! //! The caller owns the child. [`Spawned::id`] names it, and the caller collects its status, for //! example through a [`process::Child`](super::process::Child): this module never waits for a diff --git a/nvx-rs/src/guest/sys/entry/tests.rs b/nvx-rs/src/guest/sys/entry/tests.rs index c21e6a1d1..4df423a93 100644 --- a/nvx-rs/src/guest/sys/entry/tests.rs +++ b/nvx-rs/src/guest/sys/entry/tests.rs @@ -20,11 +20,9 @@ pub(crate) fn seed_inheritable_and_ambient() -> Result<(), Errno> { } /// How a test without privileges simulates the stages of an entry: the child starts without -/// new namespaces and skips the system calls of every entry stage. +/// new namespaces and skips the system calls of every entry stage but the one that fails. #[derive(Clone, Copy, Debug)] pub(crate) enum Simulation { - /// Every entry stage succeeds. - Skip, /// The stage fails with the error. Fail(LaunchStage, Errno), } diff --git a/nvx-rs/src/guest/sys/spawn/tests.rs b/nvx-rs/src/guest/sys/spawn/tests.rs index 3dcaa5415..cc815fc9c 100644 --- a/nvx-rs/src/guest/sys/spawn/tests.rs +++ b/nvx-rs/src/guest/sys/spawn/tests.rs @@ -54,49 +54,6 @@ impl ChildSpec { } } -impl PreparedChild { - /// Returns the prepared executable path for focused launch tests. - pub(crate) fn program(&self) -> &CString { - &self.program - } - - /// Returns the prepared arguments for focused launch tests. - pub(crate) fn arguments(&self) -> &[CString] { - &self.arguments - } - - /// Returns the prepared exact environment, without the physical `PWD` entry, for focused - /// launch tests. - pub(crate) fn environment(&self) -> &[CString] { - self.environment.as_deref().unwrap_or_default() - } - - /// Returns the preallocated physical `PWD` entry for focused launch tests. - pub(crate) fn physical_pwd(&self) -> Option<&[u8]> { - self.physical_pwd - .as_ref() - .map(|physical_pwd| physical_pwd.entry.as_ref()) - } - - /// Returns the environment position of the physical `PWD` entry for focused launch tests. - pub(crate) fn physical_pwd_index(&self) -> Option { - self.physical_pwd - .as_ref() - .map(|physical_pwd| physical_pwd.index) - } - - /// Returns whether the child joins a cgroup, for focused launch tests. - pub(crate) fn has_cgroup(&self) -> bool { - self.cgroup.is_some() - } - - /// Returns whether the child waits at a barrier, enters an execution, and leads a new - /// session, for focused launch tests. - pub(crate) fn barrier_entry_and_session(&self) -> (bool, bool, bool) { - (self.barrier.is_some(), self.entry.is_some(), self.session) - } -} - /// Closes standard streams of an isolated test process, and restores them when dropped, so /// that the test harness can report the outcome. struct ClosedStreams(Vec<(RawFd, OwnedFd)>); diff --git a/nvx-rs/src/host/cli/session/tests.rs b/nvx-rs/src/host/cli/session/tests.rs index 9c616c3e5..5699bbc3a 100644 --- a/nvx-rs/src/host/cli/session/tests.rs +++ b/nvx-rs/src/host/cli/session/tests.rs @@ -1064,10 +1064,7 @@ fn output_beyond_the_guest_limit_breaks_the_protocol() { worker.join().unwrap(); } -#[cfg(any( - feature = "testing", - all(feature = "guest", target_os = "linux", target_arch = "x86_64") -))] +#[cfg(feature = "testing")] #[test] fn features_of_a_guest_that_predates_them_are_none() { let (mut session, worker) = session_with(|guest| { diff --git a/nvx-rs/src/protocol/features.rs b/nvx-rs/src/protocol/features.rs index a303a5da4..1c8e95e9f 100644 --- a/nvx-rs/src/protocol/features.rs +++ b/nvx-rs/src/protocol/features.rs @@ -49,15 +49,12 @@ impl GuestFeatures { | Self::EXEC_CWD.0 | Self::HOST_MAPPING_TABLE.0, ); - /// Features announced by the managed agent that launches a sandbox container. - #[cfg(any( - feature = "testing", - all(feature = "guest", target_os = "linux", target_arch = "x86_64") - ))] + /// Features that the fake OpenVMM's guest announces for a layered sandbox, as the managed + /// agent's sandbox mode did. + #[cfg(feature = "testing")] pub(crate) const SANDBOX_AGENT: Self = Self(Self::CANCEL.0 | Self::EXEC_ENVIRONMENT.0 | Self::HOST_MAPPING_TABLE.0); - /// The features that `nvx-rs sandbox` depends on in the layered sandbox root, whose agent - /// announces [`SANDBOX_AGENT`](Self::SANDBOX_AGENT): + /// The features that `nvx-rs sandbox` depends on in the layered sandbox root: /// [`EXEC_ENVIRONMENT`](Self::EXEC_ENVIRONMENT), because `exec` sends a working directory and /// an environment in an extended `EXEC`, and /// [`HOST_MAPPING_TABLE`](Self::HOST_MAPPING_TABLE), because `start` sends the mappings of