From c235cb62beefd03087e7cfc7f7f55f0c7c6601c9 Mon Sep 17 00:00:00 2001 From: Lena Date: Sat, 1 Aug 2026 00:00:00 +0000 Subject: Enter vmm 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. --- tests/arch | 143 +++++++++++++++++++++++++++++++++ tests/argv | 151 +++++++++++++++++++++++++++++++++++ tests/boot | 250 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/clone | 46 +++++++++++ tests/config | 187 +++++++++++++++++++++++++++++++++++++++++++ tests/console | 228 ++++++++++++++++++++++++++++++++++++++++++++++++++++ tests/create | 48 +++++++++++ tests/env | 32 ++++++++ tests/list | 37 +++++++++ tests/lock | 51 ++++++++++++ tests/net | 70 ++++++++++++++++ tests/static | 17 ++++ tests/usage | 27 +++++++ tests/viewer | 44 +++++++++++ 14 files changed, 1331 insertions(+) create mode 100644 tests/arch create mode 100644 tests/argv create mode 100644 tests/boot create mode 100644 tests/clone create mode 100644 tests/config create mode 100644 tests/console create mode 100644 tests/create create mode 100644 tests/env create mode 100644 tests/list create mode 100644 tests/lock create mode 100644 tests/net create mode 100644 tests/static create mode 100644 tests/usage create mode 100644 tests/viewer (limited to 'tests') 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 < "$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 < "$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 </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 < "$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 </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 < "$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 < "$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 </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 <&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 </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 </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 </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 </dev/null 2>&1 || : +cfg bt2 </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 </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" </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 </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 < "$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 </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 </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" < "$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" < "$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 </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 </dev/null 2>&1 +cfg 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 </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 </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 </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 </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 </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 -- cgit v1.2.3