@@ -1,0 +1,286 @@
#!/usr/bin/env bash
# @tag requirements: [REQ-DEP-001, REQ-DEP-002]
# Tests for packaging/linux/qemu: the systemd units, the libvirt hook (with a fake systemctl), the
# VM wait helper (with fake gdbus and virsh) and the port allocation helper. Nothing is installed and no
# real systemd or libvirt service is started.
#
# Usage: test_sunshine_qemu.sh [test-name...] (default: every test_* function)
# Prints "ok <name>", "skip <name>: <reason>" or "FAIL <name>: <reason>" per test; exits 1 on any failure.
# shellcheck disable=SC2329 # test functions are called by name from the runner at the end
set -uo pipefail
script_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
repo="$(cd "${script_dir}/../../../.." && pwd)"
pkg="${repo}/packaging/linux/qemu"
shellcheck_bin="${SHELLCHECK:-$(command -v shellcheck || echo "${HOME}/.cache/sunshine-qemu/venv/bin/shellcheck")}"
tmp=""
failure=""
fail() {
failure="$*"
return 1
}
# expect_eq <actual> <expected> <what>
expect_eq() {
[[ "$1" == "$2" ]] || fail "$3: expected '$2', got '$1'"
}
# fake_bin <name> <script body>: an executable in ${tmp}/bin that runs the body with bash
fake_bin() {
mkdir -p "${tmp}/bin"
printf '#!/usr/bin/env bash\n%s\n' "$2" > "${tmp}/bin/$1"
chmod +x "${tmp}/bin/$1"
}
# a fake systemctl that records its arguments and fails when ${tmp}/systemctl.fail exists
fake_systemctl() {
fake_bin systemctl "echo \"\$*\" >> '${tmp}/systemctl.log'; [[ ! -e '${tmp}/systemctl.fail' ]]"
}
run_hook() {
PATH="${tmp}/bin:${PATH}" SUNSHINE_QEMU_CONF_DIR="${tmp}/etc" "${pkg}/libvirt-hook-qemu" "$@" < /dev/null
}
# verify_unit <unit file> <instance> [--user]: systemd-analyze verify with stub executables; no output allowed
verify_unit() {
local unit="$1" instance="$2" mode="${3:-}"
local dir="${tmp}/units${mode}"
mkdir -p "${dir}" "${tmp}/stub"
printf '#!/bin/sh\nexit 0\n' > "${tmp}/stub/sunshine"
cp "${tmp}/stub/sunshine" "${tmp}/stub/sunshine-qemu-wait-vm"
chmod +x "${tmp}/stub/sunshine" "${tmp}/stub/sunshine-qemu-wait-vm"
local name
name="$(basename "${unit}")"
sed -e "s|/usr/bin/sunshine|${tmp}/stub/sunshine|g" \
-e "s|/usr/lib/sunshine-qemu/sunshine-qemu-wait-vm|${tmp}/stub/sunshine-qemu-wait-vm|g" "${unit}" > "${dir}/${name}"
ln -sf "${dir}/${name}" "${dir}/${name/@/@${instance}}"
local output status
output="$(systemd-analyze ${mode:+"${mode}"} verify "${dir}/${name/@/@${instance}}" 2>&1)"
status=$?
[[ ${status} == 0 && -z "${output}" ]] || fail "systemd-analyze ${mode} verify ${name} (exit ${status}): ${output}"
}
# @tag requirements: [REQ-DEP-002]
test_system_unit_verifies() {
command -v systemd-analyze > /dev/null || {
failure="skip: systemd-analyze isn't installed"
return 2
}
verify_unit "${pkg}/sunshine-qemu@.service" win11 || return 1
verify_unit "${pkg}/sunshine-qemu@.service" 'my\x2dvm' || return 1
}
# @tag requirements: [REQ-DEP-002]
test_user_unit_verifies() {
command -v systemd-analyze > /dev/null || {
failure="skip: systemd-analyze isn't installed"
return 2
}
verify_unit "${pkg}/user/sunshine-qemu@.service" win11 --user
}
# @tag requirements: [REQ-DEP-001, REQ-DEP-002]
test_units_keep_instances_apart_and_wait_for_the_vm() {
local unit
for unit in "${pkg}/sunshine-qemu@.service" "${pkg}/user/sunshine-qemu@.service"; do
grep -qE '^StateDirectory=sunshine-qemu/%i$' "${unit}" || fail "$(basename "$(dirname "${unit}")")/$(basename "${unit}"): no per-instance StateDirectory" || return 1
grep -qE '^Environment=XDG_CONFIG_HOME=%S/sunshine-qemu/%i$' "${unit}" || fail "${unit}: Sunshine's appdata isn't per instance" || return 1
grep -qE '^ExecStart=/usr/bin/sunshine (/etc|%E)/sunshine-qemu/%I\.conf$' "${unit}" || fail "${unit}: ExecStart doesn't read the instance's config" || return 1
grep -qE '^ExecStartPre=/usr/lib/sunshine-qemu/sunshine-qemu-wait-vm (/etc|%E)/sunshine-qemu/%I\.conf$' "${unit}" || fail "${unit}: doesn't wait for the VM before Sunshine's startup check" || return 1
grep -qE '^Restart=on-failure$' "${unit}" || fail "${unit}: no Restart=on-failure" || return 1
done
grep -qE '^User=sunshine-qemu$' "${pkg}/sunshine-qemu@.service" || fail "system unit doesn't run as the dedicated user" || return 1
grep -qE '^u sunshine-qemu ' "${pkg}/sunshine-qemu.sysusers" || fail "sysusers doesn't create sunshine-qemu" || return 1
}
# @tag requirements: [REQ-DEP-002]
test_hook_starts_and_stops_opted_in_domains() {
fake_systemctl
mkdir -p "${tmp}/etc"
touch "${tmp}/etc/win11.conf"
run_hook win11 prepare begin - || return 1
run_hook win11 start begin - || return 1
run_hook win11 started begin - || return 1
run_hook win11 reconnect begin - || return 1
run_hook win11 stopped end - || return 1
run_hook win11 release end - || return 1
expect_eq "$(cat "${tmp}/systemctl.log")" $'start --no-block sunshine-qemu@win11.service\nstart --no-block sunshine-qemu@win11.service\nstop --no-block sunshine-qemu@win11.service' "systemctl calls"
}
# @tag requirements: [REQ-DEP-002]
test_hook_ignores_domains_without_a_config() {
fake_systemctl
mkdir -p "${tmp}/etc"
touch "${tmp}/etc/win11.conf"
run_hook desktop started begin - || return 1
run_hook ../etc/win11 started begin - || return 1
run_hook "" started begin - || return 1
[[ ! -e "${tmp}/systemctl.log" ]] || fail "systemctl was called: $(cat "${tmp}/systemctl.log")"
}
# @tag requirements: [REQ-DEP-002]
test_hook_escapes_names_and_never_fails_the_domain() {
fake_systemctl
mkdir -p "${tmp}/etc"
touch "${tmp}/etc/my-vm 2.conf"
touch "${tmp}/systemctl.fail"
local stderr status
stderr="$(run_hook "my-vm 2" started begin - 2>&1)"
status=$?
expect_eq "${status}" 0 "hook exit status with a failing systemctl" || return 1
expect_eq "$(cat "${tmp}/systemctl.log")" 'start --no-block sunshine-qemu@my\x2dvm\x202.service' "escaped unit" || return 1
[[ "${stderr}" == *"failed"* ]] || fail "no message about the failed systemctl: ${stderr}"
}
# @tag requirements: [REQ-DEP-002]
test_hook_reads_the_domain_xml_from_stdin() {
fake_systemctl
mkdir -p "${tmp}/etc"
touch "${tmp}/etc/win11.conf"
# libvirt writes the XML to the hook's stdin; a hook that doesn't read it breaks libvirt's pipe
local statuses
{ printf '<domain>'; head -c 1048576 /dev/zero | tr '\0' ' '; printf '</domain>\n'; } \
| PATH="${tmp}/bin:${PATH}" SUNSHINE_QEMU_CONF_DIR="${tmp}/etc" "${pkg}/libvirt-hook-qemu" win11 started begin -
statuses="${PIPESTATUS[*]}"
expect_eq "${statuses}" "0 0" "pipeline exit statuses (XML writer, hook)" || return 1
expect_eq "$(cat "${tmp}/systemctl.log")" "start --no-block sunshine-qemu@win11.service" "systemctl calls"
}
# @tag requirements: [REQ-DEP-002]
test_wait_vm_waits_for_the_qmp_socket() {
printf 'capture = qemu\nqemu_dbus_address = qmp:%s/vm.qmp\n' "${tmp}" > "${tmp}/vm.conf"
(
sleep 1
python3 -c 'import socket, sys, time; s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen(); time.sleep(5)' "${tmp}/vm.qmp"
) &
local server=$!
local start=${SECONDS} status
SUNSHINE_QEMU_WAIT_TIMEOUT=10 SUNSHINE_QEMU_WAIT_INTERVAL=0.1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/vm.conf" 2> "${tmp}/wait.log"
status=$?
kill "${server}" 2> /dev/null
wait "${server}" 2> /dev/null
expect_eq "${status}" 0 "exit status ($(cat "${tmp}/wait.log"))" || return 1
((SECONDS - start >= 1)) || fail "returned before the socket existed"
}
# @tag requirements: [REQ-DEP-002]
test_wait_vm_waits_until_qemu_owns_its_bus_name() {
# the fake gdbus answers "(false,)" for the first two calls
fake_bin gdbus "n=\$(cat '${tmp}/gdbus.count' 2> /dev/null || echo 0); echo \$((n + 1)) > '${tmp}/gdbus.count'; echo \"\$*\" >> '${tmp}/gdbus.log'; if ((n < 2)); then echo '(false,)'; else echo '(true,)'; fi"
python3 -c 'import socket, sys, time; s = socket.socket(socket.AF_UNIX); s.bind(sys.argv[1]); s.listen(); time.sleep(10)' "${tmp}/bus.sock" &
local server=$!
for _ in $(seq 1 50); do
[[ -S "${tmp}/bus.sock" ]] && break
sleep 0.05
done
printf 'qemu_dbus_address = unix:path=%s/old.sock\nqemu_dbus_address = unix:path=%s/bus.sock \n' "${tmp}" "${tmp}" > "${tmp}/vm.conf"
local status
PATH="${tmp}/bin:${PATH}" SUNSHINE_QEMU_WAIT_TIMEOUT=10 SUNSHINE_QEMU_WAIT_INTERVAL=0.1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/vm.conf" 2> "${tmp}/wait.log"
status=$?
kill "${server}" 2> /dev/null
wait "${server}" 2> /dev/null
expect_eq "${status}" 0 "exit status ($(cat "${tmp}/wait.log"))" || return 1
expect_eq "$(cat "${tmp}/gdbus.count")" 3 "NameHasOwner calls" || return 1
grep -q -- "--address unix:path=${tmp}/bus.sock --dest org.freedesktop.DBus" "${tmp}/gdbus.log" || fail "gdbus didn't ask the last configured bus: $(head -1 "${tmp}/gdbus.log")" || return 1
grep -q "NameHasOwner org.qemu" "${tmp}/gdbus.log" || fail "gdbus didn't ask for org.qemu"
}
# @tag requirements: [REQ-DEP-002]
test_wait_vm_waits_for_a_running_libvirt_domain() {
fake_bin virsh "echo \"\$*\" >> '${tmp}/virsh.log'; n=\$(wc -l < '${tmp}/virsh.log'); if ((n < 3)); then echo 'shut off'; else echo running; echo; fi"
printf 'qemu_dbus_address = libvirt:win11?uri=qemu:///system\n' > "${tmp}/vm.conf"
local status
PATH="${tmp}/bin:${PATH}" SUNSHINE_QEMU_WAIT_TIMEOUT=10 SUNSHINE_QEMU_WAIT_INTERVAL=0.1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/vm.conf" 2> "${tmp}/wait.log"
status=$?
expect_eq "${status}" 0 "exit status ($(cat "${tmp}/wait.log"))" || return 1
expect_eq "$(sort -u "${tmp}/virsh.log")" "-c qemu:///system domstate win11" "virsh calls"
}
# @tag requirements: [REQ-DEP-002]
test_wait_vm_times_out_and_skips_what_it_cant_check() {
printf 'qemu_dbus_address = qmp:%s/never.qmp\n' "${tmp}" > "${tmp}/vm.conf"
local status start=${SECONDS}
SUNSHINE_QEMU_WAIT_TIMEOUT=1 SUNSHINE_QEMU_WAIT_INTERVAL=0.1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/vm.conf" 2> "${tmp}/wait.log"
status=$?
expect_eq "${status}" 1 "exit status after the timeout" || return 1
((SECONDS - start <= 5)) || fail "took $((SECONDS - start)) s for a 1 s timeout" || return 1
grep -q "timed out" "${tmp}/wait.log" || fail "no timeout message: $(cat "${tmp}/wait.log")" || return 1
printf 'capture = qemu\n' > "${tmp}/session.conf"
SUNSHINE_QEMU_WAIT_TIMEOUT=1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/session.conf" 2> /dev/null || fail "an empty address (session bus) must not block" || return 1
# no virsh on PATH: libvirt addresses are left to Sunshine
mkdir -p "${tmp}/nobin"
ln -s "$(command -v sed)" "${tmp}/nobin/sed"
ln -s "$(command -v tail)" "${tmp}/nobin/tail"
printf 'qemu_dbus_address = libvirt:win11\n' > "${tmp}/libvirt.conf"
PATH="${tmp}/nobin" SUNSHINE_QEMU_WAIT_TIMEOUT=1 "$(command -v bash)" "${pkg}/sunshine-qemu-wait-vm" "${tmp}/libvirt.conf" 2> "${tmp}/wait.log" || fail "a libvirt address without virsh must not block: $(cat "${tmp}/wait.log")" || return 1
SUNSHINE_QEMU_WAIT_TIMEOUT=1 "${pkg}/sunshine-qemu-wait-vm" "${tmp}/missing.conf" 2> /dev/null && fail "a missing config must fail"
return 0
}
# @tag requirements: [REQ-DEP-001]
test_port_helper_allocates_non_overlapping_slots() {
local port="${pkg}/sunshine-qemu-port" etc="${tmp}/etc"
mkdir -p "${etc}"
printf 'capture = qemu\n' > "${etc}/a.conf"
printf 'capture = qemu\n' > "${etc}/b.conf"
printf 'port = 48150\n' > "${etc}/manual.conf"
expect_eq "$("${port}" --dir "${etc}" a)" 48089 "first VM, while b has no port yet (counts as 47989)" || return 1
expect_eq "$("${port}" --dir "${etc}" a)" 48089 "same answer again" || return 1
expect_eq "$("${port}" --dir "${etc}" --write a)" 48089 "write a" || return 1
grep -qx 'port = 48089' "${etc}/a.conf" || fail "--write didn't add the port" || return 1
expect_eq "$("${port}" --dir "${etc}" --write a)" 48089 "an existing port is kept" || return 1
expect_eq "$(grep -c '^port' "${etc}/a.conf")" 1 "--write doesn't add a second port" || return 1
# slot 1 is taken by a; 48189 (48184-48210) doesn't overlap manual.conf's 48150 (48145-48171)
expect_eq "$("${port}" --dir "${etc}" --write b)" 48189 "second VM skips the used slot" || return 1
"${port}" --dir "${etc}" --check || fail "--check failed on non-overlapping ports" || return 1
printf 'port = 48200\n' > "${etc}/c.conf"
local output
output="$("${port}" --dir "${etc}" --check 2>&1)" && fail "--check passed with 48189 and 48200 overlapping" || true
[[ "${output}" == *"overlaps"* ]] || fail "--check didn't name the overlap: ${output}" || return 1
expect_eq "$("${port}" --dir "${etc}" d)" 48289 "a new VM skips every overlapping slot" || return 1
"${port}" --dir "${etc}" "../x" 2> /dev/null && fail "accepted a path as VM name"
printf 'port = 65530\n' > "${etc}/c.conf"
"${port}" --dir "${etc}" --check 2> /dev/null && fail "--check accepted a range above 65535"
return 0
}
# @tag requirements: [REQ-DEP-002]
test_scripts_are_shellcheck_clean() {
[[ -x "${shellcheck_bin}" ]] || {
failure="skip: shellcheck isn't installed"
return 2
}
local output
output="$("${shellcheck_bin}" "${pkg}/libvirt-hook-qemu" "${pkg}/sunshine-qemu-wait-vm" "${pkg}/sunshine-qemu-port" "${BASH_SOURCE[0]}" 2>&1)" || fail "${output}"
}
names=("$@")
if ((${#names[@]} == 0)); then
mapfile -t names < <(declare -F | awk '$3 ~ /^test_/ { print $3 }')
fi
failed=0
for name in "${names[@]}"; do
tmp="$(mktemp -d "${TMPDIR:-/tmp}/sunshine-qemu-pkg.XXXXXX")"
failure=""
"${name}"
status=$?
case "${status}" in
0) echo "ok ${name}" ;;
2) echo "skip ${name}: ${failure#skip: }" ;;
*)
echo "FAIL ${name}: ${failure:-exit ${status}}"
failed=1
;;
esac
rm -rf "${tmp}"
done
exit "${failed}"