aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorLena <lena@omega>2026-08-01 00:00:00 +0000
committerLena <lena@omega>2026-08-01 00:00:00 +0000
commitc235cb62beefd03087e7cfc7f7f55f0c7c6601c9 (patch)
tree2776f86d9226d55c78e3d735764fcd9ecbe5e08e
downloadvmm-c235cb62beefd03087e7cfc7f7f55f0c7c6601c9.tar.gz
Enter vmmHEADmaster
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--README183
-rwxr-xr-xtest184
-rw-r--r--tests/arch143
-rw-r--r--tests/argv151
-rw-r--r--tests/boot250
-rw-r--r--tests/clone46
-rw-r--r--tests/config187
-rw-r--r--tests/console228
-rw-r--r--tests/create48
-rw-r--r--tests/env32
-rw-r--r--tests/list37
-rw-r--r--tests/lock51
-rw-r--r--tests/net70
-rw-r--r--tests/static17
-rw-r--r--tests/usage27
-rw-r--r--tests/viewer44
-rwxr-xr-xvmm1363
17 files changed, 3061 insertions, 0 deletions
diff --git a/README b/README
new file mode 100644
index 0000000..6596535
--- /dev/null
+++ b/README
@@ -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.
diff --git a/test b/test
new file mode 100755
index 0000000..4a28df5
--- /dev/null
+++ b/test
@@ -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
diff --git a/vmm b/vmm
new file mode 100755
index 0000000..52248cc
--- /dev/null
+++ b/vmm
@@ -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 "$@"