From: Christian Brauner Date: Fri, 8 May 2026 08:55:11 +0000 (+0200) Subject: test: integration test for io.systemd.MachineInstance.ReplaceStorage X-Git-Url: http://git.ipfire.org/cgi-bin/gitweb.cgi?a=commitdiff_plain;h=1360117a6497b7b9301a81be2070f645bc8f4bc9;p=thirdparty%2Fsystemd.git test: integration test for io.systemd.MachineInstance.ReplaceStorage Modelled on TEST-87-AUX-UTILS-VM.bind-volume.sh. Boots vmspawn with one boot-time bind-volume, hot-adds a runtime volume via machinectl bind-volume, then exercises ReplaceStorage: 1. happy-path replace of a runtime drive 2. successive replace (verify file_generation rotation — no node-name collisions on the second swap) 3. replace of the boot-time drive must fail with StorageImmutable 4. replace of an unknown name must fail with NoSuchStorage 5. invalid name (no provider:volume separator) must fail with InvalidParameter 6. unbind-volume after replace must succeed — proves the new file node is monitor-owned and the format-then-file teardown order in vmspawn_qmp_block_device_teardown() correctly cleans up both blockdev nodes Pushes the new backing file via varlinkctl --push-fd; the file is a plain truncate'd image. Auto-discovered by run_subtests in TEST-87-AUX-UTILS-VM.sh. Signed-off-by: Christian Brauner (Amutable) --- diff --git a/test/units/TEST-87-AUX-UTILS-VM.replace-storage.sh b/test/units/TEST-87-AUX-UTILS-VM.replace-storage.sh new file mode 100755 index 00000000000..22ff57adf83 --- /dev/null +++ b/test/units/TEST-87-AUX-UTILS-VM.replace-storage.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# SPDX-License-Identifier: LGPL-2.1-or-later +# Test io.systemd.MachineInstance.ReplaceStorage — runtime hot-swap of an +# attached storage volume's backing file via QMP blockdev-reopen. +# +# Exercises: +# - happy-path replace of a runtime-attached drive +# - successive replaces (file_generation rotation, no node-name collisions) +# - StorageImmutable rejection for boot-time attached volumes +# - NoSuchStorage rejection for unknown names +# - clean RemoveStorage after a replace (proves both old and new file nodes +# are monitor-owned and properly cleaned up) +set -eux +set -o pipefail + +# shellcheck source=test/units/util.sh +. "$(dirname "$0")"/util.sh + +if [[ -v ASAN_OPTIONS ]]; then + echo "vmspawn launches QEMU which doesn't work under ASan, skipping" + exit 0 +fi + +if ! command -v systemd-vmspawn >/dev/null 2>&1; then + echo "systemd-vmspawn not found, skipping" + exit 0 +fi + +if ! command -v storagectl >/dev/null 2>&1; then + echo "storagectl not found, skipping" + exit 0 +fi + +if ! find_qemu_binary; then + echo "QEMU not found, skipping" + exit 0 +fi + +if ! command -v mke2fs >/dev/null 2>&1; then + echo "mke2fs not found, skipping" + exit 0 +fi + +if ! test -S /run/systemd/io.systemd.StorageProvider/fs; then + echo "StorageProvider fs socket not found, skipping" + exit 0 +fi + +KERNEL="" +for k in /usr/lib/modules/"$(uname -r)"/vmlinuz /boot/vmlinuz-"$(uname -r)" /boot/vmlinuz; do + if [[ -f "$k" ]]; then + KERNEL="$k" + break + fi +done + +if [[ -z "$KERNEL" ]]; then + echo "No kernel found for direct VM boot, skipping" + exit 0 +fi + +WORKDIR="$(mktemp -d /tmp/test-replace-storage.XXXXXXXXXX)" + +at_exit() { + set +e + if [[ -n "${MACHINE:-}" ]]; then + if machinectl status "$MACHINE" &>/dev/null; then + machinectl terminate "$MACHINE" 2>/dev/null + timeout 10 bash -c "while machinectl status '$MACHINE' &>/dev/null; do sleep .5; done" 2>/dev/null + fi + fi + [[ -n "${VMSPAWN_PID:-}" ]] && { kill "$VMSPAWN_PID" 2>/dev/null; wait "$VMSPAWN_PID" 2>/dev/null; } + rm -rf "$WORKDIR" + rm -f /var/lib/storage/test-replace-storage-*.volume +} +trap at_exit EXIT + +mkdir -p "$WORKDIR/rootfs/sbin" +cat >"$WORKDIR/rootfs/sbin/init" <<'INITEOF' +#!/bin/sh +exec sleep infinity +INITEOF +chmod +x "$WORKDIR/rootfs/sbin/init" + +truncate -s 256M "$WORKDIR/root.raw" +mke2fs -t ext4 -q -d "$WORKDIR/rootfs" "$WORKDIR/root.raw" + +BOOT_VOL="test-replace-storage-boot-$$" +RUNTIME_VOL="test-replace-storage-runtime-$$" + +# Backing files for ReplaceStorage. Regular files; --push-fd opens read-only. +truncate -s 32M "$WORKDIR/new-backing-1.raw" +truncate -s 32M "$WORKDIR/new-backing-2.raw" + +wait_for_machine() { + local machine="$1" pid="$2" log="$3" + timeout 30 bash -c " + while ! machinectl list --no-legend 2>/dev/null | grep >/dev/null '$machine'; do + if ! kill -0 $pid 2>/dev/null; then + echo 'vmspawn exited before machine registration' + cat '$log' + exit 77 + fi + sleep .5 + done + " || { + local rc=$? + if [[ $rc -eq 77 ]]; then exit 0; fi + exit "$rc" + } +} + +MACHINE="test-replace-storage-$$" +systemd-vmspawn \ + --machine="$MACHINE" \ + --ram=256M \ + --image="$WORKDIR/root.raw" \ + --bind-volume="fs:${BOOT_VOL}::create=new,size=64M,template=sparse-file" \ + --linux="$KERNEL" \ + --tpm=no \ + --console=headless \ + root=/dev/vda rw \ + &>"$WORKDIR/vmspawn.log" & +VMSPAWN_PID=$! + +wait_for_machine "$MACHINE" "$VMSPAWN_PID" "$WORKDIR/vmspawn.log" +echo "Machine '$MACHINE' registered" + +VARLINK_ADDR=$(varlinkctl call /run/systemd/machine/io.systemd.Machine \ + io.systemd.Machine.List "{\"name\":\"$MACHINE\"}" | jq -r '.controlAddress') +assert_neq "$VARLINK_ADDR" "null" + +# --- Hot-add a runtime volume (target for ReplaceStorage) --- +# virtio-scsi: vmspawn's hot-add path only allocates a PCIe root port for the +# scsi controller; bare virtio-blk hot-add fails on QEMU builds that don't +# auto-pick a free slot. Same backend code path either way. +machinectl bind-volume "$MACHINE" \ + "fs:${RUNTIME_VOL}:virtio-scsi:create=new,size=32M,template=sparse-file" +echo "Hot-added runtime bind-volume" + +# varlinkctl --push-fd= opens O_RDONLY; the runtime drive is RW so the +# server rejects an RO fd with EROFS. Open RW via bash and pass the numeric fd. + +# --- Test 1: happy-path replace --- +exec {NEW_FD}<>"$WORKDIR/new-backing-1.raw" +varlinkctl --push-fd="$NEW_FD" call "$VARLINK_ADDR" \ + io.systemd.MachineInstance.ReplaceStorage \ + "{\"fileDescriptorIndex\":0,\"name\":\"fs:${RUNTIME_VOL}\"}" +exec {NEW_FD}<&- +echo "Replace #1 succeeded" + +# --- Test 2: replace again (verify file_generation rotation) --- +exec {NEW_FD}<>"$WORKDIR/new-backing-2.raw" +varlinkctl --push-fd="$NEW_FD" call "$VARLINK_ADDR" \ + io.systemd.MachineInstance.ReplaceStorage \ + "{\"fileDescriptorIndex\":0,\"name\":\"fs:${RUNTIME_VOL}\"}" +exec {NEW_FD}<&- +echo "Replace #2 succeeded" + +# --- Test 3: replace boot-time drive must fail with StorageImmutable --- +if varlinkctl --push-fd="$WORKDIR/new-backing-1.raw" call "$VARLINK_ADDR" \ + io.systemd.MachineInstance.ReplaceStorage \ + "{\"fileDescriptorIndex\":0,\"name\":\"fs:${BOOT_VOL}\"}" 2>"$WORKDIR/replace-immutable.err"; then + echo "ERROR: ReplaceStorage of boot-time drive should have failed" + cat "$WORKDIR/replace-immutable.err" + exit 1 +fi +grep StorageImmutable "$WORKDIR/replace-immutable.err" >/dev/null +echo "Boot-time drive correctly rejected with StorageImmutable" + +# --- Test 4: replace non-existent name must fail with NoSuchStorage --- +if varlinkctl --push-fd="$WORKDIR/new-backing-1.raw" call "$VARLINK_ADDR" \ + io.systemd.MachineInstance.ReplaceStorage \ + "{\"fileDescriptorIndex\":0,\"name\":\"fs:does-not-exist-$$\"}" 2>"$WORKDIR/replace-nosuch.err"; then + echo "ERROR: ReplaceStorage of non-existent drive should have failed" + cat "$WORKDIR/replace-nosuch.err" + exit 1 +fi +grep NoSuchStorage "$WORKDIR/replace-nosuch.err" >/dev/null +echo "Non-existent drive correctly rejected with NoSuchStorage" + +# --- Test 5: RO fd to RW drive must fail with EROFS --- +# varlinkctl --push-fd= opens RO; runtime drive is RW. +# Capture both stdout and stderr: errnoName "EROFS" is in the JSON reply on +# stdout; stderr only carries the human-readable strerror. +if varlinkctl --push-fd="$WORKDIR/new-backing-1.raw" call "$VARLINK_ADDR" \ + io.systemd.MachineInstance.ReplaceStorage \ + "{\"fileDescriptorIndex\":0,\"name\":\"fs:${RUNTIME_VOL}\"}" &>"$WORKDIR/replace-rofs.err"; then + echo "ERROR: ReplaceStorage with RO fd should have failed" + cat "$WORKDIR/replace-rofs.err" + exit 1 +fi +grep EROFS "$WORKDIR/replace-rofs.err" >/dev/null +echo "RO fd to RW drive correctly rejected with EROFS" + +# --- Test 6: unbind after replace (proves new file node is monitor-owned and +# the format-then-file teardown order correctly cleans up both nodes) --- +machinectl unbind-volume "$MACHINE" "fs:${RUNTIME_VOL}" +echo "Unbind after replace succeeded (cleanup of both nodes works)" + +machinectl terminate "$MACHINE" +timeout 10 bash -c "while machinectl status '$MACHINE' &>/dev/null; do sleep .5; done" +timeout 10 bash -c "while kill -0 '$VMSPAWN_PID' 2>/dev/null; do sleep .5; done" +echo "All ReplaceStorage tests passed"