diff options
| author | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
|---|---|---|
| committer | Lena <lena@omega> | 2026-08-01 00:00:00 +0000 |
| commit | c235cb62beefd03087e7cfc7f7f55f0c7c6601c9 (patch) | |
| tree | 2776f86d9226d55c78e3d735764fcd9ecbe5e08e | |
| download | vmm-master.tar.gz | |
A QEMU/KVM virtual machine manager in one POSIX shell script. Each
VM is a directory of plain files under $VMMDIR, configured by a
hand-edited KEY=VALUE file that is parsed rather than sourced, so
nothing in it can inject a raw QEMU argument.
Control is a QMP FIFO pair inside that directory, guarded by its
permissions alone; nothing binds a TCP port. The tests are TAP
without a framework and boot real guests where the host allows.
| -rw-r--r-- | README | 183 | ||||
| -rwxr-xr-x | test | 184 | ||||
| -rw-r--r-- | tests/arch | 143 | ||||
| -rw-r--r-- | tests/argv | 151 | ||||
| -rw-r--r-- | tests/boot | 250 | ||||
| -rw-r--r-- | tests/clone | 46 | ||||
| -rw-r--r-- | tests/config | 187 | ||||
| -rw-r--r-- | tests/console | 228 | ||||
| -rw-r--r-- | tests/create | 48 | ||||
| -rw-r--r-- | tests/env | 32 | ||||
| -rw-r--r-- | tests/list | 37 | ||||
| -rw-r--r-- | tests/lock | 51 | ||||
| -rw-r--r-- | tests/net | 70 | ||||
| -rw-r--r-- | tests/static | 17 | ||||
| -rw-r--r-- | tests/usage | 27 | ||||
| -rw-r--r-- | tests/viewer | 44 | ||||
| -rwxr-xr-x | vmm | 1363 |
17 files changed, 3061 insertions, 0 deletions
@@ -0,0 +1,183 @@ +vmm +=== +A QEMU/KVM virtual machine manager in one POSIX shell script. Each VM is +a directory of plain files with a KEY=VALUE config edited by hand. + + cp vmm /usr/local/bin/vmm + vmm the verbs, their arguments, the environment + +Needs qemu-img and qemu-system-x86_64 or qemu-system-aarch64, from QEMU +6.0 or newer, Linux /proc, the POSIX tools, and flock, readlink, setsid, +timeout, grep -m1 and du -h, which are not POSIX. Alpine BusyBox and +Debian base tools provide them. Native acceleration needs /dev/kvm; +HWACCEL=no uses emulation instead. `vmm viewer` needs remote-viewer, or +whatever client $VIEWER names. + + +Usage +----- + vmm create web 20G writes the config, opens $EDITOR + vmm start web + vmm console web Ctrl-] detaches + vmm stop web + +Install an OS by pointing CDROM at an ISO and booting it first: + + CDROM="/srv/iso/alpine-virt-x86_64.iso" + BOOT_ORDER=dc + +The CD is a SCSI device, so an installer with no virtio drivers will not +see it. Drop both lines once the disk is bootable. + +A GRAPHICS=vnc or spice guest puts its display on a unix socket in the VM +directory, and `vmm viewer web` hands that socket to remote-viewer. The +path goes into a URI unencoded, so a VMMDIR holding a space, #, ? or % +may not survive the client's parser. The client runs wherever vmm runs, +so from another machine forward the socket and point a viewer at the +forward instead: + + ssh -N -L 5900:/home/you/.vm/web/vnc.sock vmhost & + remote-viewer vnc://127.0.0.1:5900 + +Every VM owns `$VMMDIR/<name>/disk.qcow2`; its path is not configurable. +To run many VMs from one immutable golden image, replace each new disk +with an overlay. The overlay takes its size from the backing file: + + rm ~/.vm/web/disk.qcow2 + qemu-img create -f qcow2 -b /srv/img/alpine.qcow2 -F qcow2 \ + ~/.vm/web/disk.qcow2 + + +Configuration +------------- +One KEY=VALUE per line. A `#` in the first non-blank column starts a +comment; a line carrying a value cannot also carry one. Values may be +quoted to protect leading or trailing blanks. Unknown keys, duplicates, +unbalanced quotes and non-printable bytes are errors naming the line. +Nothing here is sourced or eval'd. + + ARCH x86_64 | aarch64 default host + HWACCEL yes | no default yes + CPUS positive integer default 2 + MEM positive integer, MiB default 2048 + IMAGE_FORMAT qcow2 | raw default qcow2 + FIRMWARE absolute path aarch64 needs it + GRAPHICS no | vnc | spice default no + NETWORK yes | no | hostonly default yes + SNAPSHOT yes | no default no + BOOT_ORDER 1-3 distinct of c(disk) d(cdrom) n(net) default c + BALLOON yes | no default yes + CDROM absolute path optional + HOSTFWD comma list of HOST:GUEST or ADDR:HOST:GUEST optional + + - BOOT_ORDER is a restriction, not a preference: QEMU runs with + -boot strict=on, so a device left out of the list never boots. A + list naming no device the config creates would restrict nothing, + and is refused. + - SNAPSHOT=yes runs the guest on an overlay that is unlinked as soon + as QEMU holds it open, so guest writes have nowhere to survive. + - GRAPHICS adds a display and USB tablet, reached with `vmm viewer`. + vnc is built into QEMU, spice is a separate module. Clipboard + sharing and guest agents are out of scope. Stop the VM before + changing graphics. + - NETWORK=hostonly isolates the guest from the host and the internet. + QEMU's own DHCP and DNS still answer, and HOSTFWD still reaches in. + - HOSTFWD binds 127.0.0.1 unless the entry names an IPv4 address of + its own, so a two field entry is unreachable from another machine. + - BALLOON=yes lets the guest return freed pages to the host as it + frees them. There is no ballooning policy to set. + - Only the host's own ARCH can use KVM. Anything else is emulated, and + aarch64 has no built-in firmware to emulate without. + - IMAGE_FORMAT describes the fixed disk. `vmm create` makes qcow2; set + raw only after deliberately replacing that file with a raw image. + + +State +----- +One directory per VM, mode 0700, under $VMMDIR: + + config KEY=VALUE, hand-edited configuration + uuid identity, feeds -uuid and the NIC MAC + disk.qcow2 created by `vmm create` + pid written and locked by QEMU itself, never by vmm + qmp.in qmp.out FIFOs carrying QMP to and from QEMU + stdout stderr QEMU's own two streams, truncated at start + console.log guest serial, truncated at start, attached or not + vnc.sock only when GRAPHICS=vnc + spice.sock only when GRAPHICS=spice + +The pidfile is authoritative. Do not remove it while QEMU is running. A +VM reading as unknown has a pidfile naming a live process vmm cannot +identify, usually a pid something else took after a crash. Nothing that +would touch a running guest acts on that VM until the pidfile is removed +by hand. + +status and list report the QEMU process, not the guest. A guest that +panics is paused rather than killed, so it still reads as running; ask +`vmm monitor <name> info status` for the guest's own view. + +Nothing caps console.log. It is truncated when the VM starts and appended +to for as long as the VM runs, so a guest that streams to its serial port +fills the filesystem it lives on. + +The MAC is derived from the uuid, so a VM keeps its DHCP lease across +restarts and a clone never collides with the VM it came from. + +create and clone write config last. A failure may leave an incomplete +directory; inspect and remove it manually before retrying. + + +Security +-------- +The VM directory is the only authentication. Whoever can write qmp.in +owns the monitor, and the monitor can attach a block device pointing at +any file QEMU can read. QEMU runs under its seccomp sandbox with +spawning denied, so the monitor cannot start a process on the host. +Nothing is ever bound to a TCP port for control. + +Disk images are trusted input: a qcow2 names its backing file in its own +header and QEMU opens it. Run `qemu-img info --backing-chain` on an image +from anywhere else. A backing file must be immutable while its overlays +exist. Never back one VM with another VM's writable disk. + + +Exit codes +---------- + 0 success, including every idempotent no-op + 1 usage, config, dependency, or operation failure + 2 no such VM + 3 VM is not running, or its monitor did not answer + 4 VM is busy, locked, or has live state vmm cannot identify + 5 VM was stopped only by force + +`vmm stop` returning 0 means the guest shut itself down. 5 means it +ignored ACPI and was killed. + + +Debugging +--------- + vmm dryrun web what would run, and whether the config parses + vmm logs web what QEMU said, what the guest printed + vmm monitor web info status ask the running guest + tail -f ~/.vm/web/console.log + +dryrun launches nothing and needs no QEMU installed, so it doubles as a +config checker anywhere. Errors name the file and the line: + + vmm: /home/you/.vm/web/config:7: unknown key: MEMROY + +A VM that will not start prints the last of QEMU's stderr. vmm removes +runtime files only after proving QEMU is gone. A busy VM has another vmm +invocation holding its lock; the kernel releases it when that process exits. +An incomplete VM directory is deliberately left for manual inspection. + + +Testing +------- + ./test every file in tests/ + ./test config argv those two only + +TAP, no framework. ./test makes a throwaway VMMDIR, defines the +assertions, and sources each file in tests/ in turn. Tests that boot a +guest on KVM skip without /dev/kvm; the emulated ones need only the +matching qemu-system binary, and its firmware for aarch64. @@ -0,0 +1,184 @@ +#!/bin/sh +# +# vmm tests. TAP output, no framework. +# +# ./test run every file in tests/ +# ./test config run tests/config only +# +# Every test runs against a throwaway VMMDIR under $TMPDIR. Tests that +# need to boot a guest are skipped when /dev/kvm is not usable, so this +# exits 0 on a machine without virtualisation. + +# shellcheck disable=SC2329 # the helpers below are called from tests/* +set -eu +umask 077 + +ROOT=$(cd "$(dirname "$0")" && pwd) +VMM=$ROOT/vmm +[ -x "$VMM" ] || { printf 'Bail out! not executable: %s\n' "$VMM"; exit 1; } + +WORK=$(mktemp -d "${TMPDIR:-/tmp}/vmm-tests.XXXXXX") +trap 'cleanup' EXIT +trap 'exit 129' HUP +trap 'exit 130' INT +trap 'exit 143' TERM + +cleanup() { + trap - EXIT HUP INT TERM + for cmdline in /proc/[0-9]*/cmdline; do + p=${cmdline#/proc/} + p=${p%/cmdline} + line= + IFS= read -r line 2>/dev/null < "$cmdline" || : + case $line in + qemu-system-*"-pidfile$WORK/vms/"*"/pid" | \ + /*/qemu-system-*"-pidfile$WORK/vms/"*"/pid") + kill -9 "$p" 2>/dev/null || : + ;; + esac + done + rm -rf "$WORK" +} + +VMMDIR=$WORK/vms +export VMMDIR +SHUTDOWN_TIMEOUT=3 +export SHUTDOWN_TIMEOUT +EDITOR=true +export EDITOR + +N=0 +FAILED=0 + +ok() { + N=$((N + 1)) + printf 'ok %s - %s\n' "$N" "$1" +} + +notok() { + N=$((N + 1)) + FAILED=$((FAILED + 1)) + printf 'not ok %s - %s\n' "$N" "$1" + shift + # Line by line: a diagnostic carrying vmm's output is usually several + # lines, and a bare one is not a comment. It is read as a result, so an + # error message mentioning 'ok' would invent a test that never ran. + for l in "$@"; do + printf '%s\n' "$l" | sed 's/^/# /' + done +} + +# skip N REASON - N assertions this host cannot make +skip() { + n=$1 + shift + while [ "$n" -gt 0 ]; do + N=$((N + 1)) + printf 'ok %s - # SKIP %s\n' "$N" "$*" + n=$((n - 1)) + done +} + +# holds DESC CMD... - require a command to succeed. 'test ! -e x' says +# the negative, so there is only the one. +holds() { + desc=$1 + shift + if "$@" >/dev/null 2>&1; then + ok "$desc" + else + notok "$desc" "failed: $*" + fi +} + +# exits CODE DESC ARGS... - run vmm and require an exact exit code +exits() { + want=$1 + desc=$2 + shift 2 + out=$("$VMM" "$@" 2>&1) && got=0 || got=$? + if [ "$got" = "$want" ]; then + ok "$desc" + else + notok "$desc" "wanted exit $want, got $got" "argv: $*" "output: $out" + fi +} + +# outputs PATTERN DESC ARGS... - run vmm and require a matching line +outputs() { + pat=$1 + desc=$2 + shift 2 + out=$("$VMM" "$@" 2>&1) && got=0 || got=$? + if [ "$got" = 0 ] && printf '%s\n' "$out" | grep -q -- "$pat"; then + ok "$desc" + else + notok "$desc" "exit $got, wanted 0 and a match for: $pat" \ + "argv: $*" "output: $out" + fi +} + +# omits PATTERN DESC ARGS... - run vmm and require no matching line +omits() { + pat=$1 + desc=$2 + shift 2 + out=$("$VMM" "$@" 2>&1) && got=0 || got=$? + if [ "$got" != 0 ]; then + notok "$desc" "exit $got, wanted 0" "argv: $*" "output: $out" + elif printf '%s\n' "$out" | grep -q -- "$pat"; then + notok "$desc" "unwanted match for: $pat" "argv: $*" "output: $out" + else + ok "$desc" + fi +} + +# refuses PATTERN DESC ARGS... - require a NON-ZERO exit AND a matching +# message. The message alone is not enough: an error downgraded to a +# warning prints the same words and still exits 0. +refuses() { + pat=$1 + desc=$2 + shift 2 + out=$("$VMM" "$@" 2>&1) && got=0 || got=$? + if [ "$got" = 0 ]; then + notok "$desc" "expected a refusal, got exit 0" "argv: $*" "output: $out" + return 0 + fi + if printf '%s\n' "$out" | grep -q -- "$pat"; then + ok "$desc" + else + notok "$desc" "exit $got but no match for: $pat" "argv: $*" "output: $out" + fi +} + +cfg() { + cat > "$VMMDIR/$1/config" +} + +# The list is an order, cheapest checks first. The loop refuses a file +# it does not name, which would otherwise never run and never be missed. +if [ $# -eq 0 ]; then + set -- static usage create config argv arch list clone lock env \ + boot net console viewer + for f in "$ROOT"/tests/*; do + case " $* " in + *" ${f##*/} "*) continue ;; + esac + printf 'Bail out! tests/%s is in no run list\n' "${f##*/}" + exit 1 + done +fi + +for t in "$@"; do + if [ ! -f "$ROOT/tests/$t" ]; then + printf 'Bail out! no such test: %s\n' "$t" + exit 1 + fi + # shellcheck source=/dev/null + . "$ROOT/tests/$t" +done + +printf '1..%s\n' "$N" +[ "$FAILED" = 0 ] || exit 1 +exit 0 diff --git a/tests/arch b/tests/arch new file mode 100644 index 0000000..2f60451 --- /dev/null +++ b/tests/arch @@ -0,0 +1,143 @@ +# ARCH picks the qemu binary, the machine type and the display device. +# Nothing here needs /dev/kvm: another architecture is emulated. + +"$VMM" create ar1 64M >/dev/null 2>&1 +FW=/usr/share/qemu/edk2-aarch64-code.fd +[ -f "$FW" ] || FW=/usr/share/AAVMF/AAVMF_CODE.fd + +cfg ar1 <<EOF +ARCH=riscv64 +EOF +refuses 'ARCH must be' "an unsupported ARCH is refused" dryrun ar1 + +cfg ar1 <<EOF +ARCH=aarch64 +EOF +refuses 'requires FIRMWARE' "aarch64 without firmware is refused" dryrun ar1 + +cfg ar1 <<EOF +FIRMWARE=firmware.fd +EOF +refuses 'FIRMWARE must be an absolute path' "a relative FIRMWARE is refused" dryrun ar1 + +# The host's own architecture is accelerated. -cpu host means nothing +# anywhere else, and asking for it under emulation is an error. +: > "$VMMDIR/ar1/config" +outputs 'accel=kvm' "the host architecture gets kvm" dryrun ar1 +outputs "-cpu 'host'" "the host architecture gets the host cpu" dryrun ar1 + +# A host with no /dev/kvm can still run its own architecture, slowly. +cfg ar1 <<EOF +HWACCEL=no +EOF +outputs 'accel=tcg' "HWACCEL=no emulates the host architecture" dryrun ar1 +outputs "-cpu 'max'" "HWACCEL=no drops -cpu host, which needs kvm" dryrun ar1 + +cfg ar1 <<EOF +HWACCEL=maybe +EOF +refuses 'HWACCEL must be' "HWACCEL takes yes or no" dryrun ar1 + +cfg ar1 <<EOF +ARCH=aarch64 +FIRMWARE="$FW" +GRAPHICS=vnc +EOF +outputs 'qemu-system-aarch64' "ARCH picks the qemu binary" dryrun ar1 +outputs "machine 'virt" "aarch64 gets the virt machine" dryrun ar1 +outputs 'accel=tcg' "a foreign architecture is emulated" dryrun ar1 +outputs "-cpu 'max'" "an emulated guest does not ask for the host cpu" dryrun ar1 +outputs "bios '$FW'" "the firmware reaches the argv" dryrun ar1 +outputs 'virtio-gpu-pci' "aarch64 gets a display device that is not VGA" dryrun ar1 + +# ARCH names a binary that may not be installed. Saying so is instant; +# leaving it to setsid costs both monitor timeouts first. +NOQEMU=$WORK/noqemu-bin +mkdir -p "$NOQEMU" +for b in flock uname od awk cat tr; do + ln -sf "$(command -v "$b")" "$NOQEMU/$b" +done +: > "$VMMDIR/ar1/config" +out=$(PATH=$NOQEMU "$VMM" start ar1 2>&1) && got=0 || got=$? +if [ "$got" != 0 ] && printf '%s' "$out" | grep -q 'qemu-system-.* is not on PATH'; then + ok "a qemu that is not installed is named at once" +else + notok "a qemu that is not installed is named at once" "exit $got: $out" +fi + +# Emulated end to end: the firmware really runs and really talks to the +# serial console, which is the whole point of passing -bios. +if [ -f "$FW" ] && command -v qemu-system-aarch64 >/dev/null 2>&1; then + cfg ar1 <<EOF +ARCH=aarch64 +FIRMWARE="$FW" +MEM=512 +CPUS=1 +EOF + exits 0 "an emulated aarch64 guest starts" start ar1 + outputs 'running' "the emulated guest answers its monitor" \ + monitor ar1 info status + sleep 2 + holds "the emulated firmware writes to the serial console" \ + test -s "$VMMDIR/ar1/console.log" + "$VMM" kill ar1 >/dev/null 2>&1 || : +else + skip 3 "no qemu-system-aarch64 or no aarch64 firmware" +fi + +# Failure after spawn must reap only that child and remove every runtime +# object it may have created. +if command -v "qemu-system-$(uname -m)" >/dev/null 2>&1; then + "$VMM" create ar2 64M >/dev/null 2>&1 + printf 'not a qcow2 image\n' > "$VMMDIR/ar2/disk.qcow2" + cfg ar2 <<EOF +HWACCEL=no +MEM=256 +CPUS=1 +EOF + exits 1 "a qemu startup failure is reported" start ar2 + holds "a failed start leaves no pidfile" test ! -e "$VMMDIR/ar2/pid" + holds "a failed start leaves no input FIFO" test ! -e "$VMMDIR/ar2/qmp.in" + holds "a failed start leaves no output FIFO" test ! -e "$VMMDIR/ar2/qmp.out" + holds "a failed start releases its lock" \ + flock -n "$VMMDIR/ar2" true +else + skip 5 "no qemu for the host architecture" +fi + +# A qemu that never opens the monitor does not die of its own accord, and +# it has written no pidfile to be found by. It is still vmm's own child. +# The stand-in does not exec, so it keeps the argv this looks for. +FAKEBIN=$WORK/fake-qemu-bin +FAKE=$FAKEBIN/qemu-system-$(uname -m) +mkdir -p "$FAKEBIN" +cat > "$FAKE" <<'EOF' +#!/bin/sh +sleep 5 +EOF +chmod +x "$FAKE" +"$VMM" create ar3 64M >/dev/null 2>&1 +out=$(PATH=$FAKEBIN:$PATH MONITOR_TIMEOUT=1 "$VMM" start ar3 2>&1) && got=0 || got=$? +if [ "$got" = 1 ]; then + ok "a start whose monitor never answers fails" +else + notok "a start whose monitor never answers fails" "exit $got: $out" +fi +left= +for cmdline in /proc/[0-9]*/cmdline; do + line= + IFS= read -r line 2>/dev/null < "$cmdline" || : + case $line in + *"-pidfile$VMMDIR/ar3/pid") + p=${cmdline#/proc/} + left="$left ${p%/cmdline}" + ;; + esac +done +if [ -z "$left" ]; then + ok "and kills the child it spawned" +else + notok "and kills the child it spawned" "still running:$left" + # shellcheck disable=SC2086 # a list of pids this loop collected + kill -9 $left 2>/dev/null || : +fi diff --git a/tests/argv b/tests/argv new file mode 100644 index 0000000..628d621 --- /dev/null +++ b/tests/argv @@ -0,0 +1,151 @@ +# The QEMU argv, as printed by dryrun. + +"$VMM" create av1 64M >/dev/null 2>&1 +IMG=$VMMDIR/av1/disk.qcow2 + +cfg av1 <<EOF +HOSTFWD="2222:22" +EOF +outputs 'hostfwd=tcp:127.0.0.1:2222-:22' "two field HOSTFWD binds loopback" dryrun av1 +outputs "-vnc 'none'" "GRAPHICS=no still arms -vnc none" dryrun av1 +outputs 'free-page-reporting=on' "balloon is on by default" dryrun av1 +outputs 'strict=on' "the boot list is a restriction, not a hint" dryrun av1 +outputs "sandbox 'on,.*spawn=deny" "the sandbox denies spawning" dryrun av1 +outputs 'panic=pause' "a panicking guest is paused, not killed" dryrun av1 +omits 'aio=io_uring' "the disk uses qemu's default AIO backend" dryrun av1 + +cfg av1 <<EOF +HOSTFWD="0.0.0.0:2222:22" +EOF +outputs 'hostfwd=tcp:0.0.0.0:2222-:22' "three field HOSTFWD honours the address" dryrun av1 + +cfg av1 <<EOF +HOSTFWD="2222:22,0.0.0.0:8080:80" +EOF +outputs 'hostfwd=tcp:127.0.0.1:2222-:22,hostfwd=tcp:0.0.0.0:8080-:80' \ + "both HOSTFWD forms in one value" dryrun av1 + +cfg av1 <<EOF +BALLOON=no +EOF +omits balloon "BALLOON=no omits the device" dryrun av1 + +cfg av1 <<EOF +NETWORK=hostonly +EOF +outputs 'restrict=on' "NETWORK=hostonly restricts the guest" dryrun av1 + +cfg av1 <<EOF +NETWORK=no +EOF +outputs "-nic 'none'" "NETWORK=no gives no NIC" dryrun av1 + +# The CDROM hangs off virtio-scsi, which every machine type has, and not +# off an IDE bus, which only x86 has. +cfg av1 <<EOF +CDROM="$IMG" +BOOT_ORDER=dc +EOF +outputs 'scsi-cd' "the CDROM is a SCSI device" dryrun av1 +outputs 'bus=scsi0.0,bootindex=1' "the CDROM takes its place in the boot order" dryrun av1 + +# A comma in the fixed disk path must be doubled, not passed through. +COMMA_DIR=$WORK/vms,comma +VMMDIR="$COMMA_DIR" "$VMM" create avc 64M >/dev/null 2>&1 +out=$(VMMDIR="$COMMA_DIR" "$VMM" dryrun avc 2>&1) && got=0 || got=$? +if [ "$got" = 0 ] && printf '%s\n' "$out" | grep -q 'vms,,comma'; then + ok "a comma in VMMDIR is escaped" +else + notok "a comma in VMMDIR is escaped" "exit $got: $out" +fi + +: > "$VMMDIR/av1/config" + +# Two VMs must not share a MAC. +"$VMM" create av2 64M >/dev/null 2>&1 +m1=$("$VMM" dryrun av1 2>/dev/null | sed -n 's/.*mac=\([0-9a-f:]*\).*/\1/p') +m2=$("$VMM" dryrun av2 2>/dev/null | sed -n 's/.*mac=\([0-9a-f:]*\).*/\1/p') +if [ -n "$m1" ] && [ "$m1" != "$m2" ]; then + ok "each VM gets its own MAC" +else + notok "each VM gets its own MAC" "av1=$m1 av2=$m2" +fi + +# dryrun output must be pasteable, so every argument is single quoted. +outputs "'node-name=disk0f,driver=file,filename=$IMG" \ + "dryrun shell-quotes its argv" dryrun av1 + +# A malformed uuid must never reach QEMU or be silently regenerated. +cp "$VMMDIR/av1/uuid" "$WORK/uuid.bak" +: > "$VMMDIR/av1/uuid" +refuses 'uuid is missing or malformed' "an empty uuid is refused" dryrun av1 +refuses 'uuid is missing or malformed' "start does not repair an empty uuid" start av1 +holds "a refused start leaves the empty uuid untouched" \ + test ! -s "$VMMDIR/av1/uuid" +printf 'zzzzzzzz-1111-2222-3333-444444444444\n' > "$VMMDIR/av1/uuid" +refuses 'uuid is missing or malformed' "a non-hex uuid is refused" dryrun av1 +printf '12345678-1111-2222-3333-44444444444z\n' > "$VMMDIR/av1/uuid" +refuses 'uuid is missing or malformed' "every uuid group is validated" dryrun av1 +cp "$WORK/uuid.bak" "$VMMDIR/av1/uuid" + +# Graphics means a display and an absolute pointer. Guest agents and +# clipboard sharing are separate policy and do not belong on this argv. +cfg av1 <<EOF +GRAPHICS=vnc +EOF +outputs 'usb-tablet' "VNC includes an absolute pointer" dryrun av1 +omits 'vdagent' "VNC includes no guest agent" dryrun av1 +omits 'virtio-serial' "VNC includes no unused agent transport" dryrun av1 + +cfg av1 <<EOF +GRAPHICS=spice +EOF +outputs 'spice.sock' "SPICE names its display socket" dryrun av1 +omits 'clipboard' "SPICE does not enable clipboard sharing" dryrun av1 +omits 'spicevmc' "SPICE includes no guest agent" dryrun av1 + +: > "$VMMDIR/av1/config" + +# dryrun launches nothing, so it must work on a host with no qemu at all. +# The README promises it as a config checker anywhere. +BIN=$WORK/dryrun-bin +mkdir -p "$BIN" +for b in uname cat sed tr grep od awk; do + ln -sf "$(command -v "$b")" "$BIN/$b" +done +holds "dryrun works with no qemu installed" \ + env PATH="$BIN" "$VMM" dryrun av1 + +# What dryrun prints is what start runs, so the printed argv must boot a +# guest vmm then recognises as that VM. The CDROM path carries a comma, +# which reaches QEMU whole only if the doubling is right. +if command -v "qemu-system-$(uname -m)" >/dev/null 2>&1; then + : > "$WORK/comma,name.iso" + "$VMM" create av3 64M >/dev/null 2>&1 + cfg av3 <<EOF +MEM=256 +CPUS=1 +HWACCEL=no +CDROM="$WORK/comma,name.iso" +BOOT_ORDER=cd +EOF + "$VMM" dryrun av3 > "$WORK/av3.sh" + sh "$WORK/av3.sh" > "$WORK/av3.out" 2>&1 & + pasted=$! + i=0 + while [ ! -s "$VMMDIR/av3/pid" ] && [ "$i" -lt 15 ]; do + sleep 1 + i=$((i + 1)) + done + out=$("$VMM" status av3 2>&1) && got=0 || got=$? + if [ "$got" = 0 ]; then + ok "the argv dryrun prints boots a guest vmm knows" + else + notok "the argv dryrun prints boots a guest vmm knows" \ + "status exit $got: $out" "$(tail -n 5 "$WORK/av3.out")" + fi + exits 0 "and vmm stops what dryrun started" kill av3 + wait "$pasted" 2>/dev/null || : +else + skip 2 "no qemu for the host architecture" +fi diff --git a/tests/boot b/tests/boot new file mode 100644 index 0000000..2f778eb --- /dev/null +++ b/tests/boot @@ -0,0 +1,250 @@ +# A real guest, booted for real. Skipped without a usable /dev/kvm, so the +# suite still exits 0 on a machine with no virtualisation. + +if [ ! -r /dev/kvm ] || [ ! -w /dev/kvm ]; then + skip 1 "no usable /dev/kvm, skipping the boot tests" + return 0 +fi + +"$VMM" create bt1 64M >/dev/null 2>&1 +cfg bt1 <<EOF +MEM=256 +CPUS=1 +EOF + +exits 0 "start boots a guest" start bt1 +PID=$(cat "$VMMDIR/bt1/pid" 2>/dev/null || echo) +if [ -n "$PID" ] && kill -0 "$PID" 2>/dev/null; then + ok "qemu is running" +else + notok "qemu is running" "$(tail -n 5 "$VMMDIR/bt1/stderr" 2>/dev/null)" +fi + +holds "qemu's stdout lands in a file" test -f "$VMMDIR/bt1/stdout" +holds "qemu's stderr lands in another" test -f "$VMMDIR/bt1/stderr" +outputs '==> .*stderr <==' "logs shows every stream" logs bt1 + +exits 0 "status of a running VM exits 0" status bt1 +outputs 'STATE=running' "status reports running" status bt1 +outputs 'running' "list reports running" list + +# The lock must be free after start, or every later verb reports busy. +holds "start releases its lock" flock -n "$VMMDIR/bt1" true + +exits 0 "start is idempotent" start bt1 +outputs 'already running' "a second start says so" start bt1 + +# restart swallows exit 5 from a stop that had to force the guest, and +# nothing else. A guest with no OS ignores ACPI, so this is that path, +# and it must come back as a different process. +old=$(cat "$VMMDIR/bt1/pid" 2>/dev/null || echo) +exits 0 "restart survives a stop that had to force" restart bt1 +new=$(cat "$VMMDIR/bt1/pid" 2>/dev/null || echo) +if [ -n "$new" ] && [ "$new" != "$old" ]; then + ok "restart comes back as a new process" +else + notok "restart comes back as a new process" "was [$old] now [$new]" +fi +outputs 'STATE=running' "the restarted guest is running" status bt1 + +# A restart that cannot start again must not report success: the config +# is broken under the guest, so stop works and start refuses. +cp "$VMMDIR/bt1/config" "$WORK/bt1.config" +cfg bt1 <<EOF +NOSUCHKEY=1 +EOF +refuses 'unknown key' "restart fails loudly when it cannot start again" restart bt1 +exits 3 "and leaves the guest stopped, not half restarted" status bt1 +cp "$WORK/bt1.config" "$VMMDIR/bt1/config" +exits 0 "start again for the tests below" start bt1 + +outputs 'running' "the monitor answers" monitor bt1 info status + +# Commands read from stdin: the count says every one was sent. +out=$(printf 'info status\ninfo version\n' | "$VMM" monitor bt1 2>&1) && got=0 || got=$? +n=$(printf '%s\n' "$out" | grep -c '"return"' || :) +if [ "$got" = 0 ] && [ "$n" = 2 ]; then + ok "monitor answers every command it reads from stdin" +else + notok "monitor answers every command it reads from stdin" \ + "exit $got, $n replies" "$out" +fi + +# A final line with no newline is a command too: dropped, it would be +# exit 0 and nothing done. +out=$(printf 'info status' | "$VMM" monitor bt1 2>&1) && got=0 || got=$? +if [ "$got" = 0 ] && printf '%s\n' "$out" | grep -q '"return"'; then + ok "monitor sends a final line with no newline" +else + notok "monitor sends a final line with no newline" "exit $got: $out" +fi + +# The command is interpolated into a JSON string. This payload closes +# that string and the object around it, so an unescaped one reaches +# QEMU's parser as a second complete command and stops the guest. An +# unescaped backslash instead makes the object unparseable, and QEMU +# refuses it with an error carrying no id, so no reply arrives at all. +outputs 'unknown command' "a quote cannot close the monitor's own object" \ + monitor bt1 'x"}},{"execute":"quit' +exits 0 "and the quit it carried never ran" status bt1 +outputs '"return"' "a backslash still leaves the monitor a parseable object" \ + monitor bt1 'info \ block' + +exits 5 "stop escalates and exits 5 on a guest with no OS" stop bt1 +holds "stop removes the pidfile once dead" test ! -e "$VMMDIR/bt1/pid" +exits 0 "stop is idempotent" stop bt1 + +# SNAPSHOT=yes must build a real overlay, never rely on -snapshot. +cp "$VMMDIR/bt1/disk.qcow2" "$WORK/bt1-before-snapshot.qcow2" +cfg bt1 <<EOF +MEM=256 +CPUS=1 +SNAPSHOT=yes +EOF +exits 0 "start with SNAPSHOT=yes" start bt1 +holds "the overlay is unlinked once qemu holds it" \ + test ! -e "$VMMDIR/bt1/ephemeral.qcow2" +# Unlinked, but open: qemu still serves the guest from it, and says so. +outputs 'ephemeral.qcow2' "the guest runs on the overlay, not the disk" \ + monitor bt1 info block +outputs '"return": ""' "a snapshot accepts guest disk writes" monitor bt1 \ + 'qemu-io -d /machine/peripheral/blk0/virtio-backend "write -P 0xa5 1048576 4096"' +exits 0 "the guest survives losing the overlay's name" status bt1 + +"$VMM" kill bt1 >/dev/null 2>&1 || : +holds "snapshot writes do not change the durable disk" qemu-img compare -q \ + "$WORK/bt1-before-snapshot.qcow2" "$VMMDIR/bt1/disk.qcow2" +exits 0 "kill is idempotent" kill bt1 + +# The same write without SNAPSHOT must survive a complete QEMU restart. +cfg bt1 <<EOF +MEM=256 +CPUS=1 +EOF +exits 0 "start without snapshot mode" start bt1 +outputs '"return": ""' "an ordinary guest accepts a disk write" monitor bt1 \ + 'qemu-io -d /machine/peripheral/blk0/virtio-backend "write -P 0x5a 2097152 4096"' +"$VMM" kill bt1 >/dev/null 2>&1 || : +exits 0 "restart after a durable disk write" start bt1 +outputs '"return": ""' "an ordinary disk write survives restart" monitor bt1 \ + 'qemu-io -d /machine/peripheral/blk0/virtio-backend "read -P 0x5a 2097152 4096"' +"$VMM" kill bt1 >/dev/null 2>&1 || : + +# A running VM must be undeletable and unclonable. +cfg bt1 <<EOF +MEM=256 +CPUS=1 +EOF +"$VMM" start bt1 >/dev/null 2>&1 || : +exits 4 "delete refuses a running VM" delete -f bt1 +exits 4 "clone refuses a running source" clone bt1 bt9 +"$VMM" kill bt1 >/dev/null 2>&1 || : +exits 0 "delete -f removes it once stopped" delete -f bt1 + +# status answers about the process, so an unparseable config must not stop +# it, and a config edited under a live guest must not make it lie. +"$VMM" create bt2 64M >/dev/null 2>&1 +cfg bt2 <<EOF +MEM=256 +CPUS=1 +EOF +"$VMM" start bt2 >/dev/null 2>&1 || : +cfg bt2 <<EOF +THIS IS NOT A CONFIG +EOF +outputs 'STATE=running' "status ignores a broken config" status bt2 +omits '^MEM=' "status reports no config values at all" status bt2 + +# A live guest must be visible under every spelling of VMMDIR, or the +# verbs that clean up delete the state of a running guest. +state=$(VMMDIR="$VMMDIR/" "$VMM" status bt2 2>/dev/null | sed -n 's/^STATE=//p') +if [ "$state" = running ]; then + ok "a running guest is visible through a trailing slash" +else + notok "a running guest is visible through a trailing slash" "got [$state]" +fi + +# vm_pid coming back empty is not proof of death. When some other live +# qemu owns the pidfile, the cleanup paths must leave the directory alone. +P2=$(cat "$VMMDIR/bt2/pid" 2>/dev/null || echo) +mkdir -p "$VMMDIR/bt3" +cp "$VMMDIR/bt2/config" "$VMMDIR/bt3/config" +printf '%s\n' "$P2" > "$VMMDIR/bt3/pid" +exits 4 "a different live qemu is reported as unknown state" stop bt3 +holds "state owned by an unrecognised live qemu is left alone" \ + test -f "$VMMDIR/bt3/pid" +state=$("$VMM" list | awk '$1 == "bt3" { print $2 }') +if [ "$state" = unknown ]; then + ok "list exposes unrecognised live qemu state" +else + notok "list exposes unrecognised live qemu state" "got [$state]" +fi +rm -rf "$VMMDIR/bt3" + +# A wedged monitor must be reported, not silently reported as success. +P=$(cat "$VMMDIR/bt2/pid" 2>/dev/null || echo) +if [ -n "$P" ]; then + kill -STOP "$P" 2>/dev/null || : + out=$(MONITOR_TIMEOUT=1 "$VMM" monitor bt2 info status 2>&1) && got=0 || got=$? + kill -CONT "$P" 2>/dev/null || : + if [ "$got" != 0 ] && printf '%s' "$out" | grep -q 'no reply'; then + ok "a silent monitor is an error, not exit 0" + else + notok "a silent monitor is an error, not exit 0" "exit $got: $out" + fi + # The shell reports the job timeout kills unless it is reaped where + # nobody can see it, and that report is not vmm's to print. + if printf '%s' "$out" | grep -q 'Terminated'; then + notok "a timed out call prints nothing but vmm's own error" "$out" + else + ok "a timed out call prints nothing but vmm's own error" + fi +else + skip 2 "bt2 did not start, cannot test the wedged monitor" +fi + +"$VMM" kill bt2 >/dev/null 2>&1 || : +exits 3 "status of the killed guest exits 3" status bt2 + +# GRAPHICS=vnc listens on a unix socket in the VM directory, and status +# reports the socket that is really there rather than what the config says. +"$VMM" create bt4 64M >/dev/null 2>&1 +cfg bt4 <<EOF +MEM=256 +CPUS=1 +GRAPHICS=vnc +EOF +if "$VMM" start bt4 >/dev/null 2>&1; then + holds "GRAPHICS=vnc creates the socket" test -S "$VMMDIR/bt4/vnc.sock" + outputs "VNC=$VMMDIR/bt4/vnc.sock" "status names the VNC socket" status bt4 + "$VMM" kill bt4 >/dev/null 2>&1 || : + holds "the socket is removed once stopped" test ! -e "$VMMDIR/bt4/vnc.sock" + out=$("$VMM" status bt4 2>&1) && got=0 || got=$? + if [ "$got" = 3 ] && ! printf '%s\n' "$out" | grep -q '^VNC='; then + ok "a stopped VM has no VNC line" + else + notok "a stopped VM has no VNC line" "exit $got: $out" + fi +else + skip 4 "this qemu cannot do GRAPHICS=vnc" +fi + +# One directory per VM means two guests are independent of each other: +# each names its own process, and killing one leaves the other alone. +"$VMM" create bt5 64M >/dev/null 2>&1 +"$VMM" create bt6 64M >/dev/null 2>&1 +for v in bt5 bt6; do + cfg "$v" <<CFG +MEM=256 +CPUS=1 +CFG + "$VMM" start "$v" >/dev/null 2>&1 || : +done +p5=$(cat "$VMMDIR/bt5/pid" 2>/dev/null || echo) +p6=$(cat "$VMMDIR/bt6/pid" 2>/dev/null || echo) +holds "two guests run at once, as two processes" \ + test -n "$p5" -a -n "$p6" -a "$p5" != "$p6" +"$VMM" kill bt5 >/dev/null 2>&1 || : +exits 3 "killing one stops that one" status bt5 +exits 0 "and leaves the other running" status bt6 +"$VMM" kill bt6 >/dev/null 2>&1 || : diff --git a/tests/clone b/tests/clone new file mode 100644 index 0000000..b1ffb92 --- /dev/null +++ b/tests/clone @@ -0,0 +1,46 @@ +# clone and delete. + +"$VMM" create cl1 64M >/dev/null 2>&1 +cfg cl1 <<EOF +HOSTFWD="2222:22" +EOF + +exits 0 "clone copies a stopped VM" clone cl1 cl2 +holds "clone copies config byte for byte" \ + cmp -s "$VMMDIR/cl1/config" "$VMMDIR/cl2/config" +holds "clone owns a separate fixed disk" \ + test -f "$VMMDIR/cl2/disk.qcow2" +holds "clone copies the source disk contents" \ + cmp -s "$VMMDIR/cl1/disk.qcow2" "$VMMDIR/cl2/disk.qcow2" +holds "clone gets a new uuid" \ + test "$(cat "$VMMDIR/cl1/uuid")" != "$(cat "$VMMDIR/cl2/uuid")" +# The uuid feeds the MAC, so a copy that kept either would collide with +# its source on the first network it joined. +m1=$("$VMM" dryrun cl1 2>/dev/null | sed -n 's/.*mac=\([0-9a-f:]*\).*/\1/p') +m2=$("$VMM" dryrun cl2 2>/dev/null | sed -n 's/.*mac=\([0-9a-f:]*\).*/\1/p') +holds "the clone's MAC differs from its source's" \ + test -n "$m1" -a "$m1" != "$m2" + +exits 1 "clone refuses an existing destination" clone cl1 cl2 + +# Copy failures leave an incomplete destination without committing config. +mv "$VMMDIR/cl1/disk.qcow2" "$WORK/cl1.qcow2" +exits 1 "clone reports a disk copy failure" clone cl1 clf +holds "a failed clone leaves an incomplete directory" test -d "$VMMDIR/clf" +holds "a failed clone does not commit a config" test ! -e "$VMMDIR/clf/config" +mv "$WORK/cl1.qcow2" "$VMMDIR/cl1/disk.qcow2" + +# A partial destination is preserved for manual inspection. +mkdir -p "$VMMDIR/clx" +printf 'partial\n' > "$VMMDIR/clx/junk" +exits 1 "clone refuses an incomplete destination" clone cl1 clx +holds "the refused clone leaves the partial contents alone" \ + test -f "$VMMDIR/clx/junk" +unlink "$VMMDIR/clx/junk" +rmdir "$VMMDIR/clx" + +exits 0 "delete -f removes a stopped VM" delete -f cl2 +holds "delete really removes the directory" test ! -e "$VMMDIR/cl2" +holds "deleting a clone leaves the source disk alone" \ + test -f "$VMMDIR/cl1/disk.qcow2" +exits 1 "delete without -f and without a tty refuses" delete cl1 diff --git a/tests/config b/tests/config new file mode 100644 index 0000000..2229bf5 --- /dev/null +++ b/tests/config @@ -0,0 +1,187 @@ +# The config parser, against hostile input. dryrun is the parser's only +# observable behaviour, so every case runs through it. + +"$VMM" create cfg1 64M >/dev/null 2>&1 + +: > "$VMMDIR/cfg1/config" +exits 0 "minimal config parses" dryrun cfg1 + +cfg cfg1 <<EOF +CPUS=2 # two of them +EOF +refuses 'config:1' "inline comment is rejected by line" dryrun cfg1 + +cfg cfg1 <<EOF +MEMORY=4096 +EOF +refuses 'unknown key' "a typo'd key is an error, not silence" dryrun cfg1 + +cfg cfg1 <<EOF +CPUS=1 +CPUS=2 +EOF +refuses 'duplicate key' "duplicate keys are an error" dryrun cfg1 + +cfg cfg1 <<EOF +IMAGE_FORMAT="qcow2,readonly=on" +EOF +refuses 'IMAGE_FORMAT must be' "IMAGE_FORMAT injection is refused" dryrun cfg1 + +cfg cfg1 <<EOF +GRAPHICS=bogus +EOF +refuses 'GRAPHICS must be' "an invalid GRAPHICS value is refused" dryrun cfg1 + +cfg cfg1 <<EOF +CPUS=abc +EOF +refuses 'CPUS must be' "non-numeric CPUS is refused" dryrun cfg1 + +cfg cfg1 <<EOF +HOSTFWD="70000:22" +EOF +refuses 'out of range' "out of range host port is refused" dryrun cfg1 + +cfg cfg1 <<EOF +HOSTFWD="0:22" +EOF +refuses 'out of range' "host port 0 is refused" dryrun cfg1 + +cfg cfg1 <<EOF +HOSTFWD="99999999999999999999:22" +EOF +refuses 'out of range' "a port too big for arithmetic is refused" dryrun cfg1 + +cfg cfg1 <<EOF +HOSTFWD=",,," +EOF +refuses 'no usable entries' "a HOSTFWD of only separators is refused" dryrun cfg1 + +# Only the dotted quad binds what it looks like: inet_aton reads 127.1 +# as 127.0.0.1 and 010.0.0.1 as octal for 8.0.0.1, and a host name is +# not an address at all. +cfg cfg1 <<EOF +HOSTFWD="localhost:2222:22" +EOF +refuses 'must be A.B.C.D' "a host name in HOSTFWD is refused" dryrun cfg1 + +cfg cfg1 <<EOF +HOSTFWD="127.0.0.256:2222:22" +EOF +refuses 'must be A.B.C.D' "an octet out of range is refused" dryrun cfg1 + +cfg cfg1 <<EOF +HOSTFWD="127.1:2222:22" +EOF +refuses 'must be A.B.C.D' "a short form address is refused" dryrun cfg1 + +cfg cfg1 <<EOF +HOSTFWD="010.0.0.1:2222:22" +EOF +refuses 'must be A.B.C.D' "an octal octet is refused" dryrun cfg1 + +cfg cfg1 <<EOF +HOSTFWD=":2222:22" +EOF +refuses 'must be A.B.C.D' "an entry with no address at all is refused" dryrun cfg1 + +# The address check runs inside the loop over entries and keeps its own +# scratch names, since the shell has no others to give it. +cfg cfg1 <<EOF +HOSTFWD="2222:22,localhost:8080:80" +EOF +refuses 'must be A.B.C.D' "a bad address in a later entry is refused" dryrun cfg1 + +# A forward the guest cannot receive is a config that lies, not a +# default. A restricted NIC is still a NIC. +cfg cfg1 <<EOF +NETWORK=no +HOSTFWD="2222:22" +EOF +refuses 'nothing to forward' "HOSTFWD without a NIC is refused" dryrun cfg1 + +cfg cfg1 <<EOF +NETWORK=hostonly +HOSTFWD="2222:22" +EOF +exits 0 "HOSTFWD with a restricted NIC is kept" dryrun cfg1 + +cfg cfg1 <<EOF +BOOT_ORDER=cc +EOF +refuses 'BOOT_ORDER must be' "a repeated BOOT_ORDER letter is refused" dryrun cfg1 + +# A boot list restricts nothing unless some device it names exists: with +# no bootindex anywhere, QEMU falls back and boots the disk the list +# left out. +cfg cfg1 <<EOF +BOOT_ORDER=d +EOF +refuses 'no device to boot' "a boot list of absent devices is refused" dryrun cfg1 + +cfg cfg1 <<EOF +BOOT_ORDER=n +NETWORK=no +EOF +refuses 'no device to boot' "netboot without a NIC is refused" dryrun cfg1 + +cfg cfg1 <<EOF +BOOT_ORDER=dc +EOF +exits 0 "a boot list that still names the disk is kept" dryrun cfg1 + +cfg cfg1 <<EOF +IMAGE="$WORK/shared.qcow2" +EOF +refuses 'unknown key: IMAGE' "the primary disk path cannot be configured" dryrun cfg1 + +cfg cfg1 <<EOF +CDROM="/tmp/install.iso +EOF +refuses 'unbalanced quote' "unbalanced quote is refused" dryrun cfg1 + +cfg cfg1 <<EOF +CPUS=$(printf '2\001') +EOF +refuses 'non-printable' "a non-printable byte is refused" dryrun cfg1 + +# Shell variables cannot carry NUL, so the parser must inspect the file +# before read(1) can silently remove it. +printf 'CPUS=2\nMEM=2\000\n' > "$VMMDIR/cfg1/config" +refuses 'config:2: NUL byte' "a NUL byte is refused on its own line" dryrun cfg1 + +# The parser is byte oriented whatever the caller's locale says, or a +# multibyte character passes for one printable character. +printf 'CDROM="/tmp/\303\251.iso"\n' > "$VMMDIR/cfg1/config" +out=$(env LANG=en_US.UTF-8 LC_CTYPE=en_US.UTF-8 "$VMM" dryrun cfg1 2>&1) && got=0 || got=$? +if [ "$got" != 0 ] && printf '%s' "$out" | grep -q 'non-printable'; then + ok "a UTF-8 byte is refused under a UTF-8 locale" +else + notok "a UTF-8 byte is refused under a UTF-8 locale" "exit $got: $out" +fi + +# A final line with no trailing newline must still be seen. +printf 'CPUS=3' > "$VMMDIR/cfg1/config" +outputs "-smp '3'" "final line without a newline is parsed" dryrun cfg1 + +# CRLF must not leak into a value. +printf 'CPUS=4\r\n' > "$VMMDIR/cfg1/config" +outputs "-smp '4'" "CRLF line endings are handled" dryrun cfg1 + +# An unreadable config must say so, not blame a key it never got to read. +chmod 000 "$VMMDIR/cfg1/config" +refuses 'not readable' "an unreadable config names the real problem" dryrun cfg1 +chmod 600 "$VMMDIR/cfg1/config" + +# edit exists to re-read the config once the editor has been through it, +# which is the only reason to run one from here. EDITOR=true is exported +# by the harness, so what is left of edit is the check. +: > "$VMMDIR/cfg1/config" +exits 0 "edit re-validates the config it just wrote" edit cfg1 +outputs 'config ok' "edit says so when the config parses" edit cfg1 + +cfg cfg1 <<EOF +MEMORY=4096 +EOF +refuses 'unknown key' "edit refuses to leave a broken config unreported" edit cfg1 +exits 2 "edit of an unknown VM exits 2" edit nosuchvm diff --git a/tests/console b/tests/console new file mode 100644 index 0000000..8ec2242 --- /dev/null +++ b/tests/console @@ -0,0 +1,228 @@ +# The serial console needs a controlling terminal, which script(1) makes. +# The guests are emulated, so no accelerator is needed here. + +if [ -t 0 ]; then + skip 1 "stdin is a terminal, a console refusal cannot be tested here" +else + "$VMM" create co0 64M >/dev/null 2>&1 + refuses 'needs a terminal' "console refuses without a terminal" console co0 +fi + +if ! command -v script >/dev/null 2>&1; then + skip 8 "no script(1) to allocate a pty" + return 0 +fi + +"$VMM" create co1 64M >/dev/null 2>&1 +cfg co1 <<EOF +MEM=256 +CPUS=1 +HWACCEL=no +EOF + +if ! "$VMM" start co1 >/dev/null 2>&1; then + skip 8 "co1 did not start" + return 0 +fi + +# Ctrl-] raises SIGINT in the pty's whole foreground process group, this +# wrapper included. A no-op trap keeps the wrapper alive to report; a +# child gets default handling either way, so vmm's own trap fires. +cat > "$WORK/console-run" <<EOF +#!/bin/sh +trap ':' INT +stty -g > "$WORK/tty.before" +"$VMM" console co1 +echo \$? > "$WORK/console.rc" +stty -g > "$WORK/tty.after" +EOF +chmod +x "$WORK/console-run" + +# One Ctrl-] per second until the console takes it and the pipe breaks, +# so a slow attach costs a retry. timeout is the backstop: a console +# that never detaches must fail, not hang. +( + i=0 + while [ "$i" -lt 15 ]; do + sleep 1 + printf '\035' + i=$((i + 1)) + done +) | timeout 60 script -q -c "$WORK/console-run" /dev/null \ + > "$WORK/console.out" 2>&1 || : + +if grep -q 'Ctrl-] detaches' "$WORK/console.out"; then + ok "console attaches and names the pty" +else + notok "console attaches and names the pty" "$(cat "$WORK/console.out")" +fi + +rc=$(cat "$WORK/console.rc" 2>/dev/null || echo missing) +if [ "$rc" = 130 ]; then + ok "Ctrl-] detaches, and says it was interrupted" +else + notok "Ctrl-] detaches, and says it was interrupted" "rc=$rc" +fi + +# A botched console leaves the terminal raw. +if cmp -s "$WORK/tty.before" "$WORK/tty.after"; then + ok "the terminal is handed back exactly as it was" +else + notok "the terminal is handed back exactly as it was" \ + "before: $(cat "$WORK/tty.before" 2>/dev/null)" \ + "after: $(cat "$WORK/tty.after" 2>/dev/null)" +fi + +exits 0 "detaching leaves the guest running" status co1 + +# SIGTERM reaches console_restore with the terminal still open, and the +# copy processes must all go with it. Ctrl-] cannot show this: it +# signals the whole foreground group and takes them down anyway. +rm -f "$WORK/term.wrapper" "$WORK/term.rc" +cat > "$WORK/console-term" <<EOF +#!/bin/sh +echo \$\$ > "$WORK/term.wrapper" +"$VMM" console co1 +echo \$? > "$WORK/term.rc" +sleep 20 +EOF +chmod +x "$WORK/console-term" +timeout 60 script -q -c "$WORK/console-term" /dev/null \ + > "$WORK/console-term.out" 2>&1 & +term_job=$! +i=0 +while ! grep -q 'Ctrl-] detaches' "$WORK/console-term.out" 2>/dev/null; do + [ "$i" -lt 15 ] || break + sleep 1 + i=$((i + 1)) +done +pts=$(tr -d '\r' < "$WORK/console-term.out" | + sed -n 's/^console co1 (\([^)]*\)).*/\1/p') +# vmm and the subshells it forks carry one argv between them, so the +# console is the match that is the wrapper's own child. Signalling any +# other one leaves vmm attached and proves nothing. +wrapper=$(cat "$WORK/term.wrapper" 2>/dev/null || echo 0) +termpid= +for cmdline in /proc/[0-9]*/cmdline; do + p=${cmdline#/proc/} + p=${p%/cmdline} + line= + IFS= read -r line 2>/dev/null < "$cmdline" || : + case $line in + *"$VMM"consoleco1) ;; + *) continue ;; + esac + if [ "$(awk '{print $4}' "/proc/$p/stat" 2>/dev/null)" = "$wrapper" ]; then + termpid=$p + fi +done +kill -TERM "$termpid" 2>/dev/null || : +i=0 +while [ ! -s "$WORK/term.rc" ] && [ "$i" -lt 10 ]; do + sleep 1 + i=$((i + 1)) +done +holding= +if [ -n "$pts" ]; then + for fd in /proc/[0-9]*/fd/0 /proc/[0-9]*/fd/1; do + if [ "$(readlink "$fd" 2>/dev/null)" = "$pts" ]; then + p=${fd#/proc/} + holding="$holding ${p%%/*}" + fi + done +fi +if [ -z "$termpid" ] || [ -z "$pts" ] || [ ! -s "$WORK/term.rc" ]; then + skip 1 "the console did not attach and exit, cannot check teardown" +elif [ -z "$holding" ]; then + ok "a terminated console leaves nothing holding the guest pty" +else + notok "a terminated console leaves nothing holding the guest pty" \ + "pty $pts still open by pid:$holding" +fi +kill "$term_job" 2>/dev/null || : +wait "$term_job" 2>/dev/null || : + +# Closing QEMU's pty makes both copies report EIO. That is an expected +# detach, not an error for the user's terminal. +rm -f "$WORK/console.rc" +( + while :; do + printf '\n' + sleep 1 + done +) | timeout 60 script -q -c "$WORK/console-run" /dev/null \ + > "$WORK/console-die.out" 2>&1 & +console_job=$! +i=0 +while ! grep -q 'Ctrl-] detaches' "$WORK/console-die.out" 2>/dev/null; do + [ "$i" -lt 15 ] || break + sleep 1 + i=$((i + 1)) +done +"$VMM" kill co1 >/dev/null 2>&1 || : +wait "$console_job" 2>/dev/null || : + +rc=$(cat "$WORK/console.rc" 2>/dev/null || echo missing) +case $rc in +0 | 130) + ok "guest death detaches the console" + ;; +*) + notok "guest death detaches the console" "rc=$rc" + ;; +esac +if grep -q 'cat:' "$WORK/console-die.out"; then + notok "guest death does not print cat errors" \ + "$(cat "$WORK/console-die.out")" +else + ok "guest death does not print cat errors" +fi + +# The writer half of the console. No OS is needed to answer, only 15 +# bytes of real mode code that poll the 16550 status register, read the +# byte and write it straight back: +# BA FD 03 mov dx,0x3fd | EC in al,dx | A8 01 test al,1 | 74 F8 jz -8 +# BA F8 03 mov dx,0x3f8 | EC in al,dx | EE out dx,al | EB F1 jmp -15 +# SeaBIOS will not boot a disk of one sector, so the image is padded. +if command -v qemu-system-x86_64 >/dev/null 2>&1; then + "$VMM" create co2 64M >/dev/null 2>&1 + dd if=/dev/zero of="$VMMDIR/co2/disk.qcow2" bs=1M count=1 2>/dev/null + printf '\272\375\003\354\250\001\164\370\272\370\003\354\356\353\361' | + dd of="$VMMDIR/co2/disk.qcow2" conv=notrunc 2>/dev/null + printf '\125\252' | + dd of="$VMMDIR/co2/disk.qcow2" bs=1 seek=510 conv=notrunc 2>/dev/null + cfg co2 <<EOF +ARCH=x86_64 +HWACCEL=no +IMAGE_FORMAT=raw +MEM=256 +CPUS=1 +EOF + if "$VMM" start co2 >/dev/null 2>&1; then + # One line per second until the console is attached to take + # one, as above. The console turns the terminal's own echo + # off, so whatever comes back came back from the guest. + ( + i=0 + while [ "$i" -lt 5 ]; do + sleep 1 + printf 'ECHO123' + i=$((i + 1)) + done + sleep 2 + printf '\035' + ) | timeout 60 script -q -c "$VMM console co2" /dev/null \ + > "$WORK/echo.out" 2>&1 || : + if grep -q ECHO123 "$WORK/echo.out"; then + ok "a keystroke reaches the guest and comes back" + else + notok "a keystroke reaches the guest and comes back" \ + "$(cat "$WORK/echo.out")" + fi + "$VMM" kill co2 >/dev/null 2>&1 || : + else + skip 1 "co2 did not start" + fi +else + skip 1 "no qemu-system-x86_64 for the echo guest" +fi diff --git a/tests/create b/tests/create new file mode 100644 index 0000000..4d95c63 --- /dev/null +++ b/tests/create @@ -0,0 +1,48 @@ +# create makes exactly what it advertises. + +exits 0 "create makes a VM" create cr1 64M +holds "create makes the disk it advertises" test -f "$VMMDIR/cr1/disk.qcow2" +holds "create writes a uuid" test -s "$VMMDIR/cr1/uuid" +holds "create writes no configurable primary disk path" \ + test -z "$(grep '^IMAGE=' "$VMMDIR/cr1/config" || :)" +exits 1 "create refuses an existing VM" create cr1 64M + +# shellcheck disable=SC2012 # the path is vmm's own and fixed, not user input +perm=$(ls -ld "$VMMDIR/cr1" | cut -c1-10) +if [ "$perm" = drwx------ ]; then + ok "VM directory is 0700" +else + notok "VM directory is 0700" "got $perm" +fi + +# The directory is the only authentication the monitor has, so every +# file in it is 0600. ls is the whole check: awk lists any regular file +# or FIFO whose mode is anything else. +# shellcheck disable=SC2012 # the names are vmm's own, not user input +bad=$(ls -l "$VMMDIR/cr1" | awk '$1 ~ /^[-p]/ && $1 !~ /^[-p]rw-------$/ { print $NF }') +if [ -z "$bad" ]; then + ok "every file create writes is 0600" +else + notok "every file create writes is 0600" "$bad" +fi + +# Validation happens before mkdir. Later failures leave their work visible. +exits 1 "create refuses a malformed size" create cr2 10GG +holds "a refused create leaves nothing behind" test ! -e "$VMMDIR/cr2" +exits 1 "create refuses a zero-sized disk" create cr2 0 +exits 1 "create refuses a zero with a suffix" create cr2 00G +exits 1 "create refuses a leading zero" create cr2 01G +exits 1 "create fails when qemu-img refuses the size" create cr3 99999999999999999999G +holds "a failed qemu-img leaves an incomplete directory" \ + test -d "$VMMDIR/cr3" +holds "a failed qemu-img does not commit a config" \ + test ! -e "$VMMDIR/cr3/config" + +# A directory without config is not guessed at or recovered automatically. +mkdir "$VMMDIR/cr4" +printf 'partial\n' > "$VMMDIR/cr4/junk" +exits 1 "create refuses an incomplete directory" create cr4 64M +holds "an incomplete directory remains untouched" \ + test -f "$VMMDIR/cr4/junk" +unlink "$VMMDIR/cr4/junk" +rmdir "$VMMDIR/cr4" diff --git a/tests/env b/tests/env new file mode 100644 index 0000000..aa0ea9b --- /dev/null +++ b/tests/env @@ -0,0 +1,32 @@ +# Environment variables reach qemu and the timeout loops, so they are +# checked once for every verb rather than inside the one that uses them. + +"$VMM" create ev1 64M >/dev/null 2>&1 + +# A differently spelled VMMDIR must name the same VMs, or a live guest +# goes invisible and the next verb deletes its disk. +a=$("$VMM" list -q | sort | tr '\n' ' ') +b=$(VMMDIR="$VMMDIR/" "$VMM" list -q | sort | tr '\n' ' ') +if [ "$a" = "$b" ]; then + ok "a trailing slash on VMMDIR names the same VMs" +else + notok "a trailing slash on VMMDIR names the same VMs" "[$a] vs [$b]" +fi + +out=$(VMMDIR=relative/path "$VMM" list 2>&1) && got=0 || got=$? +if [ "$got" != 0 ] && printf '%s' "$out" | grep -q 'absolute path'; then + ok "a relative VMMDIR is refused" +else + notok "a relative VMMDIR is refused" "exit $got: $out" +fi + +for v in SHUTDOWN_TIMEOUT MONITOR_TIMEOUT; do + for bad in abc 0 00 99999999999999999999; do + out=$(env "$v=$bad" "$VMM" list 2>&1) && got=0 || got=$? + if [ "$got" = 1 ] && printf '%s' "$out" | grep -q "$v must be"; then + ok "$v refuses $bad" + else + notok "$v refuses $bad" "exit $got: $out" + fi + done +done diff --git a/tests/list b/tests/list new file mode 100644 index 0000000..e62774d --- /dev/null +++ b/tests/list @@ -0,0 +1,37 @@ +# list, status and logs. Read-only verbs that must stay read-only. + +"$VMM" create ls1 64M >/dev/null 2>&1 + +outputs 'ls1' "list shows a VM" list +outputs 'stopped' "list shows stopped state" list +exits 1 "list rejects extra arguments" list bogus +exits 3 "status of a stopped VM exits 3" status ls1 +exits 2 "logs of an unknown VM exits 2" logs nosuchvm +exits 1 "logs refuses a zero line count" logs -n 00 ls1 +refuses 'invalid line count' "logs refuses a count too big for tail" \ + logs -n 9999999999 ls1 + +# A read only listing must never remove a pidfile. +echo 99999999 > "$VMMDIR/ls1/pid" +"$VMM" list >/dev/null 2>&1 || : +"$VMM" status ls1 >/dev/null 2>&1 || : +holds "list and status never unlink a pidfile" test -f "$VMMDIR/ls1/pid" +rm -f "$VMMDIR/ls1/pid" + +# A live pid running something other than QEMU is stale, not this VM. +# Use this test shell itself so there is no helper process to leak. +echo "$$" > "$VMMDIR/ls1/pid" +state=$("$VMM" list | awk '$1 == "ls1" { print $2 }') +if [ "$state" = stopped ]; then + ok "a live non-QEMU pid reads as stopped" +else + notok "a live non-QEMU pid reads as stopped" "got [$state]" +fi +rm -f "$VMMDIR/ls1/pid" + +# A pidfile naming a pid that is gone is the common case after a crash, +# and reading /proc for it must not make the shell complain. The same +# race happens on every kill. +echo 999999 > "$VMMDIR/ls1/pid" +omits cmdline "a pidfile naming a dead process is cleaned quietly" stop ls1 +holds "and the pidfile is cleaned up" test ! -e "$VMMDIR/ls1/pid" diff --git a/tests/lock b/tests/lock new file mode 100644 index 0000000..09c357f --- /dev/null +++ b/tests/lock @@ -0,0 +1,51 @@ +# The VM directory is the mutex. + +"$VMM" create lk1 64M >/dev/null 2>&1 + +# Every mutating verb serialises, including the two that destroy data. +exec 9<"$VMMDIR/lk1" +flock -n 9 +exits 4 "start refuses while the lock is held" start lk1 +exits 4 "delete refuses while the lock is held" delete -f lk1 +exits 4 "clone refuses while the lock is held" clone lk1 lk9 +flock -u 9 +exec 9>&- + +# The kernel releases the lock when vmm exits on an error. +cfg lk1 <<EOF +NOSUCHKEY=1 +EOF +refuses 'unknown key' "start refuses a broken config" start lk1 +exits 0 "and gives back the lock it had already taken" stop lk1 + +# Two real vmm processes, not a directory made by hand. A guest with no +# OS ignores ACPI, so stop holds the lock for SHUTDOWN_TIMEOUT, which is +# long enough to fire a start at it and require a refusal. +"$VMM" create lk2 64M >/dev/null 2>&1 +cfg lk2 <<EOF +MEM=256 +CPUS=1 +HWACCEL=no +EOF +if "$VMM" start lk2 >/dev/null 2>&1; then + "$VMM" stop lk2 >/dev/null 2>&1 & + stopper=$! + i=0 + while flock -n "$VMMDIR/lk2" true && [ "$i" -lt 5 ]; do + sleep 1 + i=$((i + 1)) + done + # That loop ends on the lock being taken or on running out of + # patience, and only the first of those says anything about vmm. + if flock -n "$VMMDIR/lk2" true; then + skip 1 "the backgrounded stop never took the lock" + else + exits 4 "a second vmm is refused while the first holds the lock" \ + start lk2 + fi + wait "$stopper" 2>/dev/null || : + holds "and the lock is available once the first is done" \ + flock -n "$VMMDIR/lk2" true +else + skip 2 "lk2 did not start" +fi diff --git a/tests/net b/tests/net new file mode 100644 index 0000000..af979e5 --- /dev/null +++ b/tests/net @@ -0,0 +1,70 @@ +# What the guest's network looks like from the host. The argv tests say +# what vmm asked for; these say what the kernel then did with it. The +# guest is emulated, so this file needs no accelerator. +# +# /proc/net/tcp lists the local address as ADDRESS:PORT in hex, the +# address little endian, the port big endian, and state 0A is LISTEN. +# 127.0.0.1 is 0100007F and 0.0.0.0 is 00000000. + +port=18022 +hex=$(printf '%04X' "$port") + +"$VMM" create nt1 64M >/dev/null 2>&1 +cfg nt1 <<EOF +MEM=256 +CPUS=1 +HWACCEL=no +HOSTFWD="$port:22" +EOF + +if grep -qi ":$hex " /proc/net/tcp; then + skip 3 "port $port is already in use on this host" +elif ! "$VMM" start nt1 >/dev/null 2>&1; then + skip 3 "nt1 did not start" +else + holds "the two field form really listens" \ + grep -qi ":$hex .* 0A " /proc/net/tcp + holds "and on 127.0.0.1, not on every interface" \ + grep -qi "0100007F:$hex " /proc/net/tcp + "$VMM" kill nt1 >/dev/null 2>&1 || : + holds "and stops listening once the guest is gone" \ + test -z "$(grep -i ":$hex .* 0A " /proc/net/tcp || :)" +fi + +cfg nt1 <<EOF +MEM=256 +CPUS=1 +HWACCEL=no +HOSTFWD="0.0.0.0:$port:22" +EOF + +if grep -qi ":$hex " /proc/net/tcp; then + skip 1 "port $port is already in use on this host" +elif ! "$VMM" start nt1 >/dev/null 2>&1; then + skip 1 "nt1 did not start" +else + holds "an explicit address is bound as written" \ + grep -qi "00000000:$hex " /proc/net/tcp + "$VMM" kill nt1 >/dev/null 2>&1 || : +fi + +# Every file the guest leaves in its directory is 0600 as well, FIFOs +# included: the monitor is reachable by anyone who can write qmp.in. +cfg nt1 <<EOF +MEM=256 +CPUS=1 +HWACCEL=no +EOF +if "$VMM" start nt1 >/dev/null 2>&1; then + # shellcheck disable=SC2012 # the names are vmm's own, not user input + bad=$(ls -l "$VMMDIR/nt1" | + awk '$1 ~ /^[-p]/ && $1 !~ /^[-p]rw-------$/ { print $NF }') + if [ -z "$bad" ]; then + ok "a running guest leaves every file 0600" + else + notok "a running guest leaves every file 0600" "$bad" + fi + "$VMM" kill nt1 >/dev/null 2>&1 || : +else + skip 1 "nt1 did not start" +fi diff --git a/tests/static b/tests/static new file mode 100644 index 0000000..192f75b --- /dev/null +++ b/tests/static @@ -0,0 +1,17 @@ +# Static checks on the script itself. + +if syn=$(sh -n "$VMM" 2>&1); then + ok "sh -n is clean" +else + notok "sh -n is clean" "$syn" +fi + +if command -v shellcheck >/dev/null 2>&1; then + if sc=$(shellcheck -s sh "$VMM" "$ROOT/test" "$ROOT"/tests/* 2>&1); then + ok "shellcheck is clean" + else + notok "shellcheck is clean" "$sc" + fi +else + skip 1 "shellcheck not installed" +fi diff --git a/tests/usage b/tests/usage new file mode 100644 index 0000000..25ec6e4 --- /dev/null +++ b/tests/usage @@ -0,0 +1,27 @@ +# Dispatch and name validation, before anything touches the filesystem. + +exits 1 "bare vmm prints usage" +exits 1 "unknown command prints usage" nosuchcommand +exits 2 "unknown VM exits 2" status nosuchvm + +# Every verb refuses a wrong argument count. The guards are one line +# each, and an inverted one is invisible until someone types the wrong +# thing: these run before any VM is named, so no VM has to exist. +for v in edit start stop restart kill status console viewer logs dryrun; do + exits 1 "$v refuses two names" "$v" a b +done +exits 1 "list refuses an argument" list a +exits 1 "clone refuses one name" clone a +exits 1 "create refuses three arguments" create a 10G x +exits 1 "monitor refuses no name at all" monitor + +exits 1 "rejects a traversing name" kill ../outside +exits 1 "rejects an empty name" start '' +exits 1 "rejects a leading dash" start -rf +exits 1 "rejects a leading dot" start .hidden +exits 1 "rejects a name with a slash" start a/b +exits 1 "rejects a 33 character name" create aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa + +mkdir -p "$WORK/outside" +"$VMM" create ../outside/pwned >/dev/null 2>&1 || : +holds "traversal creates nothing outside VMMDIR" test ! -e "$WORK/outside/pwned" diff --git a/tests/viewer b/tests/viewer new file mode 100644 index 0000000..f0357f0 --- /dev/null +++ b/tests/viewer @@ -0,0 +1,44 @@ +# The URI is the whole of what viewer decides, and VIEWER=echo reports +# it without a client anywhere. The guests here are emulated. + +"$VMM" create vw1 64M >/dev/null 2>&1 + +exits 2 "viewer of an unknown VM exits 2" viewer nosuchvm +exits 3 "viewer of a stopped VM exits 3" viewer vw1 + +cfg vw1 <<EOF +MEM=256 +CPUS=1 +HWACCEL=no +EOF +if "$VMM" start vw1 >/dev/null 2>&1; then + refuses 'has no display' "GRAPHICS=no leaves nothing to view" viewer vw1 + "$VMM" kill vw1 >/dev/null 2>&1 || : +else + skip 1 "vw1 did not start" +fi + +# The URI names the socket that is really there, not what the config +# says, so a display that failed to appear is an error rather than a +# client left dialling nothing. +for g in vnc spice; do + cfg vw1 <<EOF +MEM=256 +CPUS=1 +HWACCEL=no +GRAPHICS=$g +EOF + if ! "$VMM" start vw1 >/dev/null 2>&1; then + skip 2 "this qemu cannot do GRAPHICS=$g" + continue + fi + out=$(env VIEWER=echo "$VMM" viewer vw1 2>&1) && got=0 || got=$? + if [ "$got" = 0 ] && [ "$out" = "$g+unix://$VMMDIR/vw1/$g.sock" ]; then + ok "$g is handed to the client as $g+unix" + else + notok "$g is handed to the client as $g+unix" \ + "exit $got, wanted $g+unix://$VMMDIR/vw1/$g.sock" "got: $out" + fi + "$VMM" kill vw1 >/dev/null 2>&1 || : + exits 3 "and the killed $g guest has nothing to view" viewer vw1 +done @@ -0,0 +1,1363 @@ +#!/bin/sh +# +# vmm - QEMU/KVM virtual machine manager. State is one directory of +# plain files per VM under $VMMDIR (default $HOME/.vm). The config is +# never sourced and never eval'd, and there is no way to inject raw +# QEMU arguments. + +set -eu +umask 077 +# Exported: the parser's [:print:] and every grep, sed and tr below must +# match bytes, not whatever the caller's locale calls a character. +export LC_ALL=C + +SHUTDOWN_TIMEOUT=${SHUTDOWN_TIMEOUT:-10} +MONITOR_TIMEOUT=${MONITOR_TIMEOUT:-10} + +QMPSEQ=0 +VMPID= +PID= + +log() { + printf 'vmm: %s\n' "$*" >&2 +} + +fail() { + _code=$1 + shift + log "$@" + exit "$_code" +} + +usage() { + cat >&2 <<'EOF' +Usage: vmm <command> [args] + + create <name> [size] Create config and disk, then edit (default 10G). + edit <name> Edit config, then re-validate it. + start <name> Start VM. Does nothing if already running. + stop <name> Graceful ACPI shutdown. Exit 5 if it had to force. + restart <name> Stop then start. + kill <name> SIGKILL. Does nothing if already stopped. + status <name> Print state. Exit 0 running, 3 stopped, 4 unknown. + list [-q] List VMs and state. -q prints bare names. + console <name> Attach to the serial console. Ctrl-] detaches. + viewer <name> Open the display of a GRAPHICS=vnc or spice guest. + monitor <name> [cmd] Run one monitor command, or read them from stdin. + logs [-n N] <name> Show QEMU and console logs. + clone <src> <dst> Copy a stopped VM, new identity. + delete [-f] <name> Remove a stopped VM and its state. + dryrun <name> Validate the config and print what would run. + +VM names are 1 to 32 characters of A-Za-z0-9._- and may not start with +a dot or a dash. + +Environment: + VMMDIR=$HOME/.vm where VMs live + SHUTDOWN_TIMEOUT=10 seconds to wait for ACPI shutdown + MONITOR_TIMEOUT=10 seconds to wait for a monitor reply + EDITOR=vi editor for 'vmm create' and 'vmm edit' + VIEWER=remote-viewer display client for 'vmm viewer' +EOF + exit 1 +} + +vm_check_name() { + case ${1:-} in + '') fail 1 "empty VM name" ;; + -* | .*) fail 1 "invalid VM name: $1" ;; + *[!A-Za-z0-9_.-]*) fail 1 "invalid VM name: $1" ;; + esac + if [ ${#1} -gt 32 ]; then + fail 1 "VM name longer than 32 characters: $1" + fi +} + +# vm_pid matches the pidfile path against QEMU's argv byte for byte, so +# a second spelling of VMMDIR would hide every guest. +vmmdir_setup() { + if [ -z "${VMMDIR:-}" ]; then + if [ -z "${HOME:-}" ]; then + fail 1 "neither VMMDIR nor HOME is set" + fi + VMMDIR=$HOME/.vm + fi + case $VMMDIR in + /*) ;; + *) fail 1 "VMMDIR must be an absolute path: $VMMDIR" ;; + esac + if [ -d "$VMMDIR" ]; then + VMMDIR=$(cd "$VMMDIR" && pwd -P) || + fail 1 "cannot enter VMMDIR: $VMMDIR" + fi +} + +vm_exists() { + if [ ! -d "$1" ]; then + fail 2 "no such VM: ${1##*/}" + fi + if [ ! -f "$1/config" ]; then + fail 2 "no config: $1/config" + fi +} + +# True when PID is dead, unreaped, and down to its last thread. It holds +# no files or sockets then, whatever its procfs directory still shows. +proc_is_zombie() { + _zstat=$(cat "/proc/$1/stat" 2>/dev/null) || return 1 + _zrest=${_zstat##*) } + case $_zrest in + Z\ *) ;; + *) return 1 ;; + esac + # The group leader reaches Z while a sibling thread can still hold the + # file table they share, and QEMU's lock on the disk image with it. One + # word is the leader alone, or a pattern that matched nothing. + set -- "/proc/$1/task"/* + [ $# -eq 1 ] +} + +# Set PID to the QEMU that owns this VM, or empty when it is stopped. +vm_pid() { + PID= + _pf=$1/pid + if [ ! -f "$_pf" ]; then + return 0 + fi + _p= + IFS= read -r _p 2>/dev/null < "$_pf" || : + case $_p in + '' | *[!0-9]*) return 0 ;; + esac + # -pidfile is vmm's final argv pair. ash and dash discard procfs's NUL + # separators here, leaving an exact suffix that needs no helper process. + _cmdline= + IFS= read -r _cmdline 2>/dev/null < "/proc/$_p/cmdline" || : + case $_cmdline in + qemu-system-*"-pidfile$_pf" | /*/qemu-system-*"-pidfile$_pf") + PID=$_p + ;; + esac +} + +# True when state left after vm_pid returned empty is safe to remove. +# An unrecognised live QEMU is preserved so the caller can report it. +vm_state_is_stale() { + _sf=$1/pid + if [ ! -f "$_sf" ]; then + return 0 + fi + _sp=$(cat "$_sf" 2>/dev/null) || return 0 + case $_sp in + '' | *[!0-9]*) return 0 ;; + esac + if proc_is_zombie "$_sp"; then + return 0 + fi + # A recycled pid running something else is not this VM. An unreadable + # exe link is another user's process: refused too, because + # unidentifiable is not the same as dead. + if _exe=$(readlink "/proc/$_sp/exe" 2>/dev/null); then + : + elif [ -d "/proc/$_sp" ]; then + return 1 + else + return 0 + fi + case ${_exe##*/} in + qemu-system-*) return 1 ;; + esac + return 0 +} + +# Exit 3 when the guest is stopped, exit 4 when live state vmm cannot +# identify is in the way. +vm_require_running() { + vm_pid "$1" + if [ -n "$PID" ]; then + return 0 + fi + if ! vm_state_is_stale "$1"; then + fail 4 "$1 has unrecognised live QEMU state" + fi + fail 3 "${1##*/} is not running" +} + +# Echoes nothing for a malformed uuid. QEMU rejects one before it opens +# the monitor FIFOs, so it must never reach the argv. +vm_uuid() { + _u=$(cat "$1/uuid" 2>/dev/null) || return 0 + case $_u in + ????????-????-????-????-????????????) ;; + *) return 0 ;; + esac + _hex=$(printf '%s' "$_u" | tr -d '-') + if [ ${#_hex} -ne 32 ]; then + return 0 + fi + case $_hex in + *[!0-9a-f]*) return 0 ;; + esac + printf '%s' "$_u" +} + +# The kernel releases the lock when vmm exits or is killed, so there is no +# stale state to recover. Every child that can outlive vmm closes fd 9, or +# the lock outlives vmm with it. +lock_acquire() { + command -v flock >/dev/null 2>&1 || fail 1 "flock is not on PATH" + exec 9<"$1" + flock -n 9 || fail 4 "busy: another vmm holds $1" +} + +config_fail() { + fail 1 "$CONFIG_PATH:$CONFIG_LINE: $*" +} + +# QEMU passes the address to inet_aton, which also reads 127.1, octal +# and hexadecimal. Only the dotted quad is accepted, so an address means +# what it looks like. +ipv4_check() { + case $1 in + .* | *. | *..* | *[!0-9.]*) return 1 ;; + esac + _qr=$1 + _qn=0 + while [ -n "$_qr" ]; do + case $_qr in + *.*) _qo=${_qr%%.*}; _qr=${_qr#*.} ;; + *) _qo=$_qr; _qr= ;; + esac + # A leading zero is octal to inet_aton, and four digits are out + # of range before [ has to compare them. + case $_qo in + 0?* | ????*) return 1 ;; + esac + if [ "$_qo" -gt 255 ]; then + return 1 + fi + _qn=$((_qn + 1)) + done + [ "$_qn" -eq 4 ] +} + +# Validated while the config is read, so config_fail names the line. +hostfwd_check() { + _rest=$1 + _found=no + while [ -n "$_rest" ]; do + case $_rest in + *,*) _e=${_rest%%,*}; _rest=${_rest#*,} ;; + *) _e=$_rest; _rest= ;; + esac + case $_e in + '') continue ;; + *:*:*) _a=${_e%%:*}; _t=${_e#*:}; _p1=${_t%%:*}; _p2=${_t#*:} ;; + *:*) _a=127.0.0.1; _p1=${_e%%:*}; _p2=${_e#*:} ;; + *) config_fail "HOSTFWD entry is not HOST:GUEST or ADDR:HOST:GUEST: $_e" ;; + esac + _found=yes + if ! ipv4_check "$_a"; then + config_fail "HOSTFWD bind address must be A.B.C.D: $_e" + fi + # Six digits or more is out of range without arithmetic, which + # stops [ from choking on a number too big for it. + for _p in "$_p1" "$_p2"; do + case $_p in + '' | *[!0-9]* | 0* | ??????*) + config_fail "HOSTFWD port out of range 1-65535: $_e" + ;; + esac + if [ "$_p" -gt 65535 ]; then + config_fail "HOSTFWD port out of range 1-65535: $_e" + fi + done + done + if [ -n "$1" ] && [ "$_found" = no ]; then + config_fail "HOSTFWD has no usable entries: $1" + fi +} + +# The two field form binds loopback, where a bare tcp::PORT would listen +# on every interface. +hostfwd_args() { + _rest=$1 + while [ -n "$_rest" ]; do + case $_rest in + *,*) _e=${_rest%%,*}; _rest=${_rest#*,} ;; + *) _e=$_rest; _rest= ;; + esac + case $_e in + '') continue ;; + *:*:*) printf ',hostfwd=tcp:%s-:%s' "${_e%:*}" "${_e##*:}" ;; + *) printf ',hostfwd=tcp:127.0.0.1:%s-:%s' "${_e%%:*}" "${_e#*:}" ;; + esac + done +} + +# Sets the globals every other function reads. +config_load() { + CONFIG_PATH=$1 + CR=$(printf '\r') + HOSTARCH=$(uname -m) + if [ ! -r "$CONFIG_PATH" ]; then + fail 1 "$CONFIG_PATH: not readable" + fi + # read(1) cannot carry NUL and would silently remove it. + _nul_line=$(od -An -tu1 -v "$CONFIG_PATH" | awk ' + { for (i = 1; i <= NF; i++) { + if ($i == 0) { print line + 1; exit } + if ($i == 10) line++ + } }') + if [ -n "$_nul_line" ]; then + CONFIG_LINE=$_nul_line + config_fail "NUL byte in line" + fi + + CPUS=2 + MEM=2048 + ARCH=$HOSTARCH + HWACCEL=yes + FIRMWARE= + DISK=${CONFIG_PATH%/*}/disk.qcow2 + IMAGE_FORMAT=qcow2 + GRAPHICS=no + NETWORK=yes + SNAPSHOT=no + BOOT_ORDER=c + BALLOON=yes + CDROM= + HOSTFWD= + + _seen=' ' + CONFIG_LINE=0 + + # Fed by a redirect, never a pipe: ash runs a pipeline in a subshell + # and every value set here would be lost. + while IFS= read -r _line || [ -n "$_line" ]; do + CONFIG_LINE=$((CONFIG_LINE + 1)) + + _line=${_line%"$CR"} + _ws=${_line%%[![:blank:]]*}; _line=${_line#"$_ws"} + _ws=${_line##*[![:blank:]]}; _line=${_line%"$_ws"} + + case $_line in + '' | '#'*) continue ;; + *[![:print:]]*) config_fail "non-printable byte in line" ;; + *=*) ;; + *) config_fail "not a KEY=VALUE line: $_line" ;; + esac + + _key=${_line%%=*} + _val=${_line#*=} + + _ws=${_key##*[![:blank:]]}; _key=${_key%"$_ws"} + _ws=${_val%%[![:blank:]]*}; _val=${_val#"$_ws"} + + case $_key in + '' | *[!A-Za-z0-9_]*) + config_fail "not a valid key: $_key" + ;; + esac + + case $_val in + '"'*'"') + _val=${_val#\"} + _val=${_val%\"} + ;; + '"'* | *'"') + config_fail "unbalanced quote in value for $_key" + ;; + esac + + case $_seen in + *" $_key "*) config_fail "duplicate key: $_key" ;; + esac + _seen="$_seen$_key " + + case $_key in + CPUS) + case $_val in + '' | *[!0-9]* | 0*) + config_fail "CPUS must be a positive integer: $_val" + ;; + esac + CPUS=$_val + ;; + MEM) + case $_val in + '' | *[!0-9]* | 0*) + config_fail "MEM must be a positive integer in MiB: $_val" + ;; + esac + MEM=$_val + ;; + ARCH) + case $_val in + x86_64 | aarch64) ARCH=$_val ;; + *) config_fail "ARCH must be x86_64 or aarch64: $_val" ;; + esac + ;; + HWACCEL) + case $_val in + yes | no) HWACCEL=$_val ;; + *) config_fail "HWACCEL must be yes or no: $_val" ;; + esac + ;; + FIRMWARE) + case $_val in + /*) FIRMWARE=$_val ;; + *) config_fail "FIRMWARE must be an absolute path: $_val" ;; + esac + ;; + CDROM) + case $_val in + /*) CDROM=$_val ;; + *) config_fail "CDROM must be an absolute path: $_val" ;; + esac + ;; + IMAGE_FORMAT) + # An allowlist because the value is interpolated into a + # comma-separated option string: "qcow2,readonly=on" + # would start a VM whose writes go nowhere. + case $_val in + qcow2 | raw) IMAGE_FORMAT=$_val ;; + *) config_fail "IMAGE_FORMAT must be qcow2 or raw: $_val" ;; + esac + ;; + GRAPHICS) + case $_val in + no | vnc | spice) GRAPHICS=$_val ;; + *) config_fail "GRAPHICS must be no, vnc or spice: $_val" ;; + esac + ;; + NETWORK) + case $_val in + yes | no | hostonly) NETWORK=$_val ;; + *) config_fail "NETWORK must be yes, no or hostonly: $_val" ;; + esac + ;; + SNAPSHOT) + case $_val in + yes | no) SNAPSHOT=$_val ;; + *) config_fail "SNAPSHOT must be yes or no: $_val" ;; + esac + ;; + BALLOON) + case $_val in + yes | no) BALLOON=$_val ;; + *) config_fail "BALLOON must be yes or no: $_val" ;; + esac + ;; + BOOT_ORDER) + # Every legal value, rather than a loop working out which + # strings of c, d and n name a device no more than once. + case $_val in + c | d | n | cd | cn | dc | dn | nc | nd | \ + cdn | cnd | dcn | dnc | ncd | ndc) + BOOT_ORDER=$_val + ;; + *) + config_fail "BOOT_ORDER must be 1-3 distinct of c, d, n: $_val" + ;; + esac + ;; + HOSTFWD) + HOSTFWD=$_val + hostfwd_check "$_val" + ;; + *) + config_fail "unknown key: $_key" + ;; + esac + done < "$CONFIG_PATH" + + # Without firmware the machine starts and executes nothing. + if [ "$ARCH" = aarch64 ] && [ -z "$FIRMWARE" ]; then + fail 1 "$CONFIG_PATH: ARCH=aarch64 requires FIRMWARE" + fi + # -nic none has nowhere to forward to, and the builder would drop + # the request without a word. + if [ "$NETWORK" = no ] && [ -n "$HOSTFWD" ]; then + fail 1 "$CONFIG_PATH: NETWORK=no leaves HOSTFWD nothing to forward" + fi + # -boot strict=on restricts the guest to the devices carrying a + # bootindex, and only a device this config creates gets one. A list + # naming none of them restricts nothing: the guest boots the disk + # that the list left out. + _have=c + [ -z "$CDROM" ] || _have=${_have}d + [ "$NETWORK" = no ] || _have=${_have}n + case $BOOT_ORDER in + *[$_have]*) ;; + *) fail 1 "$CONFIG_PATH: BOOT_ORDER=$BOOT_ORDER has no device to boot" ;; + esac + arch_setup +} + +# KVM needs the guest to be the machine it runs on, so HWACCEL=no and a +# foreign ARCH come to the same thing: emulation, where -cpu host does +# not exist. +arch_setup() { + case $ARCH in + x86_64) + MACHINE=q35 + VGA=virtio-vga + ;; + aarch64) + MACHINE=virt,gic-version=max + VGA=virtio-gpu-pci + ;; + *) + fail 1 "$CONFIG_PATH: unsupported host architecture: $ARCH" + ;; + esac + if [ "$HWACCEL" = yes ] && [ "$ARCH" = "$HOSTARCH" ]; then + MACHINE=$MACHINE,accel=kvm + CPU=host + else + MACHINE=$MACHINE,accel=tcg + CPU=max + fi + QEMU=qemu-system-$ARCH +} + +# Returns 1 and prints nothing when no reply came within MONITOR_TIMEOUT, +# so a silent monitor is not an empty answer. No liveness precheck: start +# has to talk to a QEMU that has no pidfile yet. +qmp() { + _d=$1 + _body=$2 + QMPSEQ=$((QMPSEQ + 1)) + _id="vmm.$$.$QMPSEQ" + + # Without the FIFOs, tee would create a regular file where the pipe + # belongs and wedge the monitor for the life of the guest. + if [ ! -p "$_d/qmp.in" ] || [ ! -p "$_d/qmp.out" ]; then + return 1 + fi + + # qmp_capabilities is valid once per QEMU process, not once per + # writer; the duplicate error carries no id, so the filter skips it. + # + # Never write the FIFO with a shell redirect: the shell opens it + # before exec'ing timeout, so the open(2) blocks forever. Only + # "printf | timeout N tee fifo" is bounded. The subshell keeps the + # shell's report of the killed job off the terminal. + ( + printf '{"execute":"qmp_capabilities"}\n{%s,"id":"%s"}\n' \ + "$_body" "$_id" \ + | timeout "$MONITOR_TIMEOUT" tee "$_d/qmp.in" 9>&- + ) >/dev/null 2>&1 || : + # The id picks this reply out of the greeting, of events, and of + # replies orphaned by an earlier timeout, which would leave the FIFO + # off by one forever. busybox timeout exits 143 where GNU exits 124. + _reply=$( { timeout "$MONITOR_TIMEOUT" grep -m1 -F "\"$_id\"" \ + "$_d/qmp.out" 9>&-; } 2>/dev/null ) || : + if [ -z "$_reply" ]; then + return 1 + fi + printf '%s\n' "$_reply" +} + +# The command is interpolated into a JSON string, so its backslashes and +# quotes escape. +monitor_send() { + case $2 in + *[![:print:]]*) fail 1 "monitor command has a non-printable byte" ;; + esac + _esc=$(printf '%s' "$2" | sed 's/\\/\\\\/g; s/"/\\"/g') + qmp "$1" "\"execute\":\"human-monitor-command\",\"arguments\":{\"command-line\":\"$_esc\"}" +} + +# QEMU splits option strings on commas, so an interpolated path must +# double its own. A raw comma is only a deprecation warning: the VM +# starts misconfigured. +qemu_comma() { + printf '%s' "$1" | sed 's/,/,,/g' +} + +# dryrun's output has to paste back into a shell unchanged. An embedded +# quote ends the string, escapes itself and starts a new one: '\'' +shell_quote() { + printf "'" + printf '%s' "$1" | sed "s/'/'\\\\''/g" + printf "'" +} + +# One builder, so what dryrun prints and what start runs cannot drift +# apart. +vm_build() { + _action=$1 + _name=$2 + _d=$VMMDIR/$_name + _uuid=$(vm_uuid "$_d") + if [ -z "$_uuid" ]; then + fail 1 "$_d/uuid is missing or malformed" + fi + + # bootindex supersedes -boot order= entirely, so emitting both would + # guarantee a config that lies about itself. + _bi_disk= + _bi_cd= + _bi_net= + _r=$BOOT_ORDER + _bn=0 + while [ -n "$_r" ]; do + _c=${_r%"${_r#?}"} + _r=${_r#?} + _bn=$((_bn + 1)) + case $_c in + c) _bi_disk=$_bn ;; + d) _bi_cd=$_bn ;; + n) _bi_net=$_bn ;; + esac + done + + # A locally administered MAC derived from the uuid, so a VM keeps its + # DHCP lease and a clone never collides with its source. + _h=${_uuid%%-*} + _m1=${_h%??????} + _h=${_h#??} + _m2=${_h%????} + _h=${_h#??} + _m3=${_h%??} + + _disk=$DISK + _dfmt=$IMAGE_FORMAT + if [ "$SNAPSHOT" = yes ]; then + # QEMU's -snapshot is ignored for -blockdev nodes: the base + # would be opened read-write with no overlay at all. + _disk=$_d/ephemeral.qcow2 + _dfmt=qcow2 + fi + + set -- "$QEMU" \ + -nodefaults \ + -no-user-config \ + -name "guest=$_name,process=vmm/$_name" \ + -uuid "$_uuid" \ + -machine "$MACHINE" \ + -cpu "$CPU" \ + -smp "$CPUS" \ + -m "$MEM" \ + -rtc base=utc \ + -boot strict=on \ + -action reboot=reset \ + -action shutdown=poweroff \ + -action panic=pause \ + -device pvpanic-pci \ + -sandbox on,obsolete=deny,elevateprivileges=deny,spawn=deny,resourcecontrol=deny \ + -msg timestamp=on \ + -blockdev "node-name=disk0f,driver=file,filename=$(qemu_comma "$_disk"),discard=unmap" \ + -blockdev "node-name=disk0,driver=$_dfmt,file=disk0f,discard=unmap,detect-zeroes=unmap" \ + -device "virtio-blk-pci,id=blk0,drive=disk0,serial=vmm-$_name${_bi_disk:+,bootindex=$_bi_disk}" \ + -device virtio-rng-pci,id=rng0 + + if [ "$BALLOON" = yes ]; then + # free-page-reporting lets the guest hand freed pages back to the + # host continuously, with no ballooning policy to manage. + set -- "$@" -device virtio-balloon-pci,id=balloon0,free-page-reporting=on + fi + + if [ -n "$FIRMWARE" ]; then + set -- "$@" -bios "$FIRMWARE" + fi + + # SCSI rather than IDE, which only x86 machines have. + if [ -n "$CDROM" ]; then + set -- "$@" \ + -device virtio-scsi-pci,id=scsi0 \ + -blockdev "node-name=cd0f,driver=file,filename=$(qemu_comma "$CDROM"),read-only=on" \ + -blockdev node-name=cd0,driver=raw,file=cd0f,read-only=on \ + -device "scsi-cd,id=cd0dev,drive=cd0,bus=scsi0.0${_bi_cd:+,bootindex=$_bi_cd}" + fi + + if [ "$NETWORK" = no ]; then + set -- "$@" -nic none + else + _nd="user,id=n0" + if [ "$NETWORK" = hostonly ]; then + _nd="$_nd,restrict=on" + fi + _nd="$_nd$(hostfwd_args "$HOSTFWD")" + set -- "$@" \ + -netdev "$_nd" \ + -device "virtio-net-pci,id=nic0,netdev=n0,mac=52:54:00:$_m1:$_m2:$_m3${_bi_net:+,bootindex=$_bi_net}" + fi + + # -vga none always: a non-VGA display device does not suppress the + # default adapter, and two adapters is a confusing guest. + set -- "$@" -vga none + + case $GRAPHICS in + no) + set -- "$@" -vnc none + ;; + vnc) + set -- "$@" \ + -device "$VGA" \ + -device qemu-xhci,id=xhci \ + -device usb-tablet,bus=xhci.0 \ + -vnc "unix:$(qemu_comma "$_d/vnc.sock")" + ;; + spice) + set -- "$@" \ + -device "$VGA" \ + -device qemu-xhci,id=xhci \ + -device usb-tablet,bus=xhci.0 \ + -display none \ + -spice "unix=on,addr=$(qemu_comma "$_d/spice.sock"),disable-ticketing=on" + ;; + esac + + set -- "$@" \ + -chardev "pty,id=con0,logfile=$(qemu_comma "$_d/console.log"),logappend=on" \ + -serial chardev:con0 \ + -chardev "pipe,id=qmp0,path=$(qemu_comma "$_d/qmp")" \ + -mon chardev=qmp0,mode=control \ + -pidfile "$_d/pid" + + case $_action in + print) + # The overlay and the FIFOs first: QEMU will not make them for + # itself. No side effects, printing never touches the filesystem. + if [ "$SNAPSHOT" = yes ]; then + printf 'qemu-img create -f qcow2 -b ' + shell_quote "$DISK" + printf ' -F %s ' "$IMAGE_FORMAT" + shell_quote "$_d/ephemeral.qcow2" + printf '\n' + fi + printf 'mkfifo ' + shell_quote "$_d/qmp.in" + printf ' ' + shell_quote "$_d/qmp.out" + printf ' 2>/dev/null || :\n' + shell_quote "$1" + shift + for _a in "$@"; do + case $_a in + -*) printf ' \\\n%s' "$_a" ;; + *) printf ' '; shell_quote "$_a" ;; + esac + done + printf '\n' + ;; + spawn) + # Never -daemonize: with the sandbox denying elevateprivileges + # it fails with exit 1 and empty stderr. setsid detaches the + # guest from this terminal. + setsid "$@" 9>&- >"$_d/stdout" 2>"$_d/stderr" & + VMPID=$! + ;; + *) + fail 1 "vm_build: no such action: $_action" + ;; + esac +} + +vm_wait_gone() { + _left=$2 + vm_pid "$1" + while [ -n "$PID" ]; do + if [ "$_left" -le 0 ]; then + return 1 + fi + _left=$((_left - 1)) + sleep 1 + vm_pid "$1" + done +} + +# What survives SIGKILL is stuck in the kernel, not deciding, so five +# seconds is generous. +vm_force_kill() { + vm_pid "$1" + _current=$PID + if [ -z "$_current" ]; then + if vm_state_is_stale "$1"; then + return 0 + fi + return 1 + fi + if [ "$_current" != "$2" ]; then + return 1 + fi + # ESRCH is a guest that died between vm_pid and the signal. + kill -9 "$2" 2>/dev/null || : + # Wait for staleness, not for vm_pid to go empty: a dying QEMU loses its + # argv and its /proc/PID/exe link before it leaves /proc, and that window + # is indistinguishable from state vm_state_is_stale must refuse to touch. + _fk=5 + while ! vm_state_is_stale "$1"; do + if [ "$_fk" -le 0 ]; then + return 1 + fi + _fk=$((_fk - 1)) + sleep 1 + done +} + +# A directory whose pidfile still names a live QEMU is left exactly as +# it is. +vm_cleanup() { + if ! vm_state_is_stale "$1"; then + return 0 + fi + rm -f "$1/qmp.in" "$1/qmp.out" "$1/vnc.sock" "$1/spice.sock" \ + "$1/ephemeral.qcow2" "$1/pid" +} + +cmd_create() { + case $# in + 1 | 2) ;; + *) usage ;; + esac + name=$1 + size=${2:-10G} + vm_check_name "$name" + case $size in + *[KMGTkmgt]) _amount=${size%?} ;; + *) _amount=$size ;; + esac + case $_amount in + '' | *[!0-9]* | 0*) fail 1 "invalid disk size: $size" ;; + esac + command -v qemu-img >/dev/null 2>&1 || fail 1 "qemu-img is not on PATH" + + mkdir -p "$VMMDIR" + VMMDIR=$(cd "$VMMDIR" && pwd -P) || + fail 1 "cannot enter VMMDIR: $VMMDIR" + d=$VMMDIR/$name + + mkdir "$d" 2>/dev/null || fail 1 "cannot create VM directory: $d" + cat /proc/sys/kernel/random/uuid > "$d/uuid" + qemu-img create -f qcow2 "$d/disk.qcow2" "$size" >/dev/null + + cat > "$d/config" <<EOF +# vmm config for '$name'. The README documents the keys; vmm names the +# legal values of any it refuses. + +CPUS=2 +MEM=2048 +IMAGE_FORMAT=qcow2 +GRAPHICS=no +NETWORK=yes +SNAPSHOT=no +BOOT_ORDER=c +BALLOON=yes + +# Optional: +# CDROM="/srv/iso/alpine-virt-x86_64.iso" +# HOSTFWD="2222:22,8080:80" +# ARCH=aarch64 +# FIRMWARE="/usr/share/qemu/edk2-aarch64-code.fd" +# HWACCEL=no +EOF + + printf 'created %s (%s)\n' "$d" "$size" + if [ -t 0 ]; then + cmd_edit "$name" + else + printf 'next: vmm edit %s\n' "$name" + fi +} + +cmd_edit() { + [ $# -eq 1 ] || usage + vm_check_name "$1" + d=$VMMDIR/$1 + vm_exists "$d" + ${EDITOR:-vi} "$d/config" + config_load "$d/config" + printf 'config ok\n' +} + +cmd_dryrun() { + [ $# -eq 1 ] || usage + vm_check_name "$1" + d=$VMMDIR/$1 + vm_exists "$d" + config_load "$d/config" + vm_build print "$1" +} + +cmd_start() { + [ $# -eq 1 ] || usage + name=$1 + vm_check_name "$name" + d=$VMMDIR/$name + vm_exists "$d" + + lock_acquire "$d" + vm_pid "$d" + if [ -n "$PID" ]; then + printf '%s is already running\n' "$name" + return 0 + fi + # vm_pid says no, but something alive still owns this state. Never + # clear another process's files on a guess. + if ! vm_state_is_stale "$d"; then + fail 4 "$d has unrecognised live QEMU state; refusing to touch it" + fi + + config_load "$d/config" + if [ -z "$(vm_uuid "$d")" ]; then + fail 1 "$d/uuid is missing or malformed" + fi + + # A missing binary would fail inside setsid, and the monitor exchange + # would wait out both timeouts before anything said so. + if ! command -v "$QEMU" >/dev/null 2>&1; then + fail 1 "$QEMU is not on PATH" + fi + + # sun_path holds 108 bytes and /spice.sock is the longest name added. + if [ "$GRAPHICS" != no ] && [ ${#d} -gt 96 ]; then + fail 1 "$d is too long for a unix socket path; shorten VMMDIR or the VM name" + fi + + # QEMU unlinks its own pidfile on a clean exit but not after SIGKILL. + vm_cleanup "$d" + + if [ "$SNAPSHOT" = yes ]; then + if ! qemu-img create -f qcow2 -b "$DISK" -F "$IMAGE_FORMAT" \ + "$d/ephemeral.qcow2" >/dev/null; then + rm -f "$d/ephemeral.qcow2" + fail 1 "$name failed to create its snapshot overlay" + fi + fi + + mkfifo "$d/qmp.in" "$d/qmp.out" + : > "$d/console.log" + + vm_build spawn "$name" + + # Opening the QMP FIFO is the readiness barrier: it cannot complete + # until QEMU is far enough along to open its end. No sleep required. + if ! qmp "$d" '"execute":"query-status"' >/dev/null; then + # A QEMU that has not written its pidfile yet is invisible to + # vm_force_kill, which would leave it running. This child is + # vmm's own and unreaped, so the pid is still its own to signal. + kill -9 "$VMPID" 2>/dev/null || : + if vm_force_kill "$d" "$VMPID"; then + vm_cleanup "$d" + else + log "$name could not stop after its startup failure; state was left intact" + fi + log "$name failed to start, last lines of $d/stderr:" + tail -n 20 "$d/stderr" >&2 || : + exit 1 + fi + + # The monitor answered, so QEMU has opened every blockdev. Unlinked + # now, the overlay is held by that descriptor alone: the guest runs on + # it, and QEMU exiting frees the blocks. + if [ "$SNAPSHOT" = yes ]; then + rm -f "$d/ephemeral.qcow2" + fi + + vm_pid "$d" + printf '%s started (pid %s)\n' "$name" "$PID" +} + +cmd_stop() { + [ $# -eq 1 ] || usage + name=$1 + vm_check_name "$name" + d=$VMMDIR/$name + vm_exists "$d" + + lock_acquire "$d" + vm_pid "$d" + pid=$PID + if [ -z "$pid" ]; then + if ! vm_state_is_stale "$d"; then + fail 4 "$d has unrecognised live QEMU state; refusing to stop it" + fi + printf '%s is not running\n' "$name" + vm_cleanup "$d" + return 0 + fi + + if ! qmp "$d" '"execute":"system_powerdown"' >/dev/null; then + log "$name did not acknowledge system_powerdown" + fi + + if vm_wait_gone "$d" "$SHUTDOWN_TIMEOUT"; then + vm_cleanup "$d" + printf '%s stopped\n' "$name" + return 0 + fi + + log "$name ignored ACPI shutdown for ${SHUTDOWN_TIMEOUT}s, killing" + if ! vm_force_kill "$d" "$pid"; then + fail 4 "$name did not die after SIGKILL; state was left intact" + fi + vm_cleanup "$d" + fail 5 "$name was stopped by force" +} + +cmd_kill() { + [ $# -eq 1 ] || usage + vm_check_name "$1" + d=$VMMDIR/$1 + vm_exists "$d" + + lock_acquire "$d" + vm_pid "$d" + pid=$PID + if [ -z "$pid" ]; then + if ! vm_state_is_stale "$d"; then + fail 4 "$d has unrecognised live QEMU state; refusing to kill it" + fi + printf '%s is not running\n' "$1" + vm_cleanup "$d" + return 0 + fi + if ! vm_force_kill "$d" "$pid"; then + fail 4 "$1 did not die after SIGKILL; state was left intact" + fi + vm_cleanup "$d" + printf '%s killed\n' "$1" +} + +cmd_restart() { + [ $# -eq 1 ] || usage + # In a subshell so a forced stop, which exits 5, still starts. Any + # other failure propagates. + ( cmd_stop "$1" ) && rc=0 || rc=$? + if [ "$rc" != 0 ] && [ "$rc" != 5 ]; then + exit "$rc" + fi + cmd_start "$1" +} + +cmd_status() { + [ $# -eq 1 ] || usage + vm_check_name "$1" + d=$VMMDIR/$1 + vm_exists "$d" + + # The config is not read here: what the guest got is only knowable + # from what is on the filesystem while it runs. + vm_pid "$d" + pid=$PID + if [ -z "$pid" ]; then + if ! vm_state_is_stale "$d"; then + pid=$(cat "$d/pid" 2>/dev/null) || : + printf 'STATE=unknown\nPID=%s\n' "${pid:-?}" + exit 4 + fi + printf 'STATE=stopped\nPID=-\n' + exit 3 + fi + printf 'STATE=running\nPID=%s\n' "$pid" + if [ -S "$d/vnc.sock" ]; then + printf 'VNC=%s\n' "$d/vnc.sock" + fi + if [ -S "$d/spice.sock" ]; then + printf 'SPICE=%s\n' "$d/spice.sock" + fi +} + +cmd_list() { + quiet=no + if [ $# -gt 0 ] && [ "$1" = -q ]; then + quiet=yes + shift + fi + [ $# -eq 0 ] || usage + if [ ! -d "$VMMDIR" ]; then + return 0 + fi + + w=4 + for e in "$VMMDIR"/*; do + if [ ! -f "$e/config" ]; then + continue + fi + n=${e##*/} + if [ ${#n} -gt "$w" ]; then + w=${#n} + fi + done + + if [ "$quiet" = no ]; then + printf "%-${w}s %-7s %s\n" NAME STATE PID + fi + for e in "$VMMDIR"/*; do + if [ ! -f "$e/config" ]; then + continue + fi + n=${e##*/} + if [ "$quiet" = yes ]; then + printf '%s\n' "$n" + continue + fi + vm_pid "$e" + p=$PID + if [ -n "$p" ]; then + printf "%-${w}s %-7s %s\n" "$n" running "$p" + elif ! vm_state_is_stale "$e"; then + p=$(cat "$e/pid" 2>/dev/null) || : + printf "%-${w}s %-7s %s\n" "$n" unknown "${p:-?}" + else + printf "%-${w}s %-7s %s\n" "$n" stopped - + fi + done +} + +console_restore() { + kill "$reader" "$writer" 2>/dev/null || : + stty "$old" 2>/dev/null || : + wait "$reader" 2>/dev/null || : + wait "$writer" 2>/dev/null || : +} + +cmd_console() { + [ $# -eq 1 ] || usage + vm_check_name "$1" + d=$VMMDIR/$1 + vm_exists "$d" + if [ ! -t 0 ]; then + fail 1 "console needs a terminal on stdin; try: tail -f $d/console.log" + fi + + lock_acquire "$d" + vm_require_running "$d" + reply=$(qmp "$d" '"execute":"query-chardev"') || reply= + # The pty is allocated afresh at every start, so it is asked for, never + # cached. + pts=$(printf '%s' "$reply" | sed -n 's/.*"filename": *"pty:\([^"]*\)".*/\1/p') + # Dropped before attaching: a console stays for as long as a human + # wants it, and stop must not wait that out. + exec 9>&- + + if [ -z "$pts" ] || [ ! -c "$pts" ]; then + fail 1 "$1 has no serial pty; see $d/console.log" + fi + + printf 'console %s (%s), Ctrl-] detaches\n' "$1" "$pts" >&2 + old=$(stty -g) + reader= + writer= + trap 'console_restore' EXIT + trap 'exit 130' INT + trap 'exit 143' TERM + trap 'exit 129' HUP + # isig with intr ^] keeps Ctrl-C for the guest and makes Ctrl-] the + # detach key. susp undef stops Ctrl-Z suspending vmm while raw. + stty raw -echo isig intr '^]' susp undef + # The reader signals this shell when the guest goes away, so a dead + # guest detaches. Its cat is a background child under a trap because + # console_restore kills the subshell alone, leaving a cat holding the + # guest's pty. $! expands when the signal arrives, not before. + { + trap 'kill $! 2>/dev/null || :; exit 0' HUP INT TERM + cat < "$pts" 2>/dev/null & + wait + kill -INT $$ 2>/dev/null || : + } & + reader=$! + cat /dev/tty > "$pts" 2>/dev/null & + writer=$! + # wait, not a foreground cat: a signal interrupts wait at once, where + # read(2) would defer the trap and leave the terminal raw. + wait "$writer" 2>/dev/null || : +} + +# The socket that is there is what the running guest got. No lock and no +# monitor round trip: unlike the serial pty, the path is fixed. +cmd_viewer() { + [ $# -eq 1 ] || usage + vm_check_name "$1" + d=$VMMDIR/$1 + vm_exists "$d" + vm_require_running "$d" + + if [ -S "$d/vnc.sock" ]; then + uri=vnc+unix://$d/vnc.sock + elif [ -S "$d/spice.sock" ]; then + uri=spice+unix://$d/spice.sock + else + fail 1 "$1 has no display; set GRAPHICS and restart it" + fi + + # Unquoted so VIEWER can carry its own options, as EDITOR does. exec + # replaces vmm, so no shell waits on the client. + exec ${VIEWER:-remote-viewer} "$uri" +} + +cmd_monitor() { + [ $# -ge 1 ] || usage + name=$1 + shift + vm_check_name "$name" + d=$VMMDIR/$name + vm_exists "$d" + + # Held for the whole session: the monitor is one FIFO with one reply + # stream, so a second reader would answer with the first one's replies. + lock_acquire "$d" + vm_require_running "$d" + + if [ $# -gt 0 ]; then + if ! monitor_send "$d" "$*"; then + fail 3 "no reply from the monitor of $name within ${MONITOR_TIMEOUT}s" + fi + return 0 + fi + + rc=0 + while :; do + if [ -t 0 ]; then + printf '(qemu) ' >&2 + fi + IFS= read -r line || [ -n "$line" ] || break + if [ -z "$line" ]; then + continue + fi + if ! monitor_send "$d" "$line"; then + log "no reply for: $line" + rc=3 + fi + done + if [ "$rc" != 0 ]; then + exit "$rc" + fi +} + +cmd_logs() { + n=50 + if [ $# -gt 1 ] && [ "$1" = -n ]; then + n=$2 + shift 2 + # Capped in length, so tail is never handed a count it refuses. + case $n in + '' | *[!0-9]* | 0* | ??????????*) + fail 1 "invalid line count: $n" + ;; + esac + fi + [ $# -eq 1 ] || usage + vm_check_name "$1" + d=$VMMDIR/$1 + vm_exists "$d" + + for f in "$d/stdout" "$d/stderr" "$d/console.log"; do + printf '==> %s <==\n' "$f" + if [ -f "$f" ]; then + tail -n "$n" "$f" + else + printf '(none)\n' + fi + done +} + +cmd_clone() { + [ $# -eq 2 ] || usage + src=$1 + dst=$2 + vm_check_name "$src" + vm_check_name "$dst" + sd=$VMMDIR/$src + dd=$VMMDIR/$dst + vm_exists "$sd" + + # The source is locked for the whole copy: without it, a concurrent + # start could boot the guest halfway through reading its disk. + lock_acquire "$sd" + vm_pid "$sd" + if [ -n "$PID" ]; then + fail 4 "$src is running; stop it before cloning" + fi + if ! vm_state_is_stale "$sd"; then + fail 4 "$sd has unrecognised live QEMU state; refusing to clone it" + fi + mkdir "$dd" 2>/dev/null || fail 1 "cannot create VM directory: $dd" + cat /proc/sys/kernel/random/uuid > "$dd/uuid" + cp "$sd/disk.qcow2" "$dd/disk.qcow2" + cp "$sd/config" "$dd/config" + printf 'cloned %s to %s\n' "$src" "$dst" +} + +cmd_delete() { + force=no + if [ $# -gt 0 ] && [ "$1" = -f ]; then + force=yes + shift + fi + [ $# -eq 1 ] || usage + name=$1 + vm_check_name "$name" + d=$VMMDIR/$name + vm_exists "$d" + + if [ "$force" = no ]; then + if [ ! -t 0 ]; then + fail 1 "refusing to delete $d without -f when stdin is not a tty" + fi + printf 'about to remove %s\n' "$d" + du -sh "$d" 2>/dev/null || : + printf 'type the VM name to confirm: ' + read -r answer || answer= + if [ "$answer" != "$name" ]; then + fail 1 "not confirmed" + fi + fi + + # Locked after the prompt, so a human typing cannot block start and + # stop, and re-checked because the answer may have changed since. + lock_acquire "$d" + vm_pid "$d" + if [ -n "$PID" ]; then + fail 4 "$name is running; stop it before deleting" + fi + if ! vm_state_is_stale "$d"; then + fail 4 "$d has unrecognised live QEMU state; refusing to delete" + fi + rm -rf "$d" + printf 'deleted %s\n' "$d" +} + +main() { + [ $# -ge 1 ] || usage + cmd=$1 + shift + + # Capped in length, so [ never has to compare a number too big for it. + case $SHUTDOWN_TIMEOUT in + '' | *[!0-9]* | 0* | ??????????*) + fail 1 "SHUTDOWN_TIMEOUT must be a positive integer: $SHUTDOWN_TIMEOUT" + ;; + esac + case $MONITOR_TIMEOUT in + '' | *[!0-9]* | 0* | ??????????*) + fail 1 "MONITOR_TIMEOUT must be a positive integer: $MONITOR_TIMEOUT" + ;; + esac + vmmdir_setup + + case $cmd in + create) cmd_create "$@" ;; + edit) cmd_edit "$@" ;; + start) cmd_start "$@" ;; + stop) cmd_stop "$@" ;; + restart) cmd_restart "$@" ;; + kill) cmd_kill "$@" ;; + status) cmd_status "$@" ;; + list) cmd_list "$@" ;; + console) cmd_console "$@" ;; + viewer) cmd_viewer "$@" ;; + monitor) cmd_monitor "$@" ;; + logs) cmd_logs "$@" ;; + clone) cmd_clone "$@" ;; + delete) cmd_delete "$@" ;; + dryrun) cmd_dryrun "$@" ;; + *) usage ;; + esac +} + +main "$@" |