ref:e6a28789f70bd57b9fede3a9826a11aa862d9275

build(linux): add systemd units and a libvirt hook for one Sunshine per VM

packaging/linux/qemu: - sunshine-qemu@.service runs /usr/bin/sunshine /etc/sunshine-qemu/<vm>.conf as a dedicated user (sunshine-qemu.sysusers) with StateDirectory and XDG_CONFIG_HOME per instance, Restart=on-failure and some sandboxing; user/sunshine-qemu@.service is the rootless variant reading ~/.config/sunshine-qemu/<vm>.conf. - sunshine-qemu-wait-vm is the units' ExecStartPre: Sunshine checks QEMU's display once at startup, so it waits until QEMU owns org.qemu on the bus, the QMP socket exists, or virsh reports the libvirt domain running. - libvirt-hook-qemu starts the unit with --no-block on started/reconnect and stops it on stopped, only for domains with a config file, and never fails or blocks the domain operation (it drains the XML on stdin). - example.conf documents a per-VM config. tests/packaging/linux/qemu/test_sunshine_qemu.sh runs systemd-analyze verify on both units (stub executables, no output allowed), the hook with a fake systemctl, the wait helper with fake gdbus/virsh, the port helper and shellcheck. Each check was confirmed to fail on a mutated script. Refs #6 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SHA: e6a28789f70bd57b9fede3a9826a11aa862d9275
Author: Cole Christensen <cole.christensen@gmail.com>
Date: 2026-09-13 01:20
Parents: 4fc4de6
7 files changed +558 -0
Type
packaging/linux/qemu/example.conf +47 −0
@@ -1,0 +1,47 @@
# Example Sunshine configuration for one QEMU VM. Copy it to /etc/sunshine-qemu/<vm>.conf (system unit)
# or ~/.config/sunshine-qemu/<vm>.conf (user unit), where <vm> is the VM's name (the libvirt domain name
# when the libvirt hook starts the unit). See docs/qemu.md.
# Take the picture, audio and input from QEMU's D-Bus display.
capture = qemu
# Where QEMU's display is. One of:
# libvirt:<domain> a system libvirt domain with <graphics type='dbus' p2p='yes'/>;
# add ?uri=qemu:///session for session libvirt
# qmp:/run/sunshine-qemu/vm.qmp a QEMU started with -display dbus,p2p=on and a QMP socket for Sunshine
# unix:path=/run/vm/bus.sock a D-Bus bus where QEMU (-display dbus,addr=...) owns org.qemu
qemu_dbus_address = libvirt:example
# Base port. Every port of this instance is an offset from it (port - 5 to port + 21), so VMs use ports
# 100 apart: sunshine-qemu-port --dir /etc/sunshine-qemu --write <vm> picks the next free one.
port = 48089
# Name shown in Moonlight and advertised over mDNS; keep it unique per host.
sunshine_name = example
# Per-VM state. Relative paths are resolved in the instance's state directory
# (/var/lib/sunshine-qemu/<vm>/sunshine or ~/.local/state/sunshine-qemu/<vm>/sunshine), so pairing,
# credentials and certificates are separate for every VM.
file_state = sunshine_state.json
credentials_file = sunshine_credentials.json
pkey = credentials/cakey.pem
cert = credentials/cacert.pem
file_apps = apps.json
log_path = sunshine.log
# Encoder: nvenc, vaapi, vulkan or software. With DMABUF scanouts (gl=on) QEMU must render on the
# encoder's GPU (adapter_name for VAAPI).
encoder = software
# adapter_name = /dev/dri/renderD128
# Graphical console to stream, by id or label; empty streams the first one.
# output_name = 0
# Several instances on one host must not fight over router port mappings.
upnp = disabled
# No tray icon for a system service.
system_tray = disabled
# Web UI (port + 1): only from this machine by default; use lan to manage the VM from the network.
origin_web_ui_allowed = pc
packaging/linux/qemu/libvirt-hook-qemu +54 −0
@@ -1,0 +1,54 @@
#!/usr/bin/env bash
# libvirt QEMU hook: start and stop sunshine-qemu@<domain>.service with the domain.
#
# Install as /etc/libvirt/hooks/qemu, or on libvirt 6.5 and newer as
# /etc/libvirt/hooks/qemu.d/sunshine-qemu (restart virtqemud/libvirtd after adding the first hook).
#
# libvirt runs it as: qemu <domain> <operation> <sub-operation> <extra>, with the domain XML on stdin.
# It only acts on domains that opted in by having a config file, <conf-dir>/<domain>.conf:
# started, reconnect systemctl start --no-block sunshine-qemu@<domain>.service
# stopped systemctl stop --no-block sunshine-qemu@<domain>.service
# Every other operation is ignored. The hook never fails the domain operation and never waits for
# Sunshine: libvirt holds the domain's lock while hooks run, and a hook that calls back into libvirt
# (as Sunshine does for qemu_dbus_address = libvirt:<domain>) would deadlock, so the unit starts
# asynchronously and connects once the domain is up.
#
# Environment (for tests):
# SUNSHINE_QEMU_CONF_DIR config directory (default: /etc/sunshine-qemu)
# SUNSHINE_QEMU_UNIT unit template name (default: sunshine-qemu@)
set -uo pipefail
domain="${1:-}"
operation="${2:-}"
conf_dir="${SUNSHINE_QEMU_CONF_DIR:-/etc/sunshine-qemu}"
unit_template="${SUNSHINE_QEMU_UNIT:-sunshine-qemu@}"
# libvirt writes the domain XML to stdin; read it so libvirt never sees a broken pipe
if [[ ! -t 0 ]]; then
cat > /dev/null
fi
log() {
echo "sunshine-qemu hook: $*" >&2
}
case "${operation}" in
started | reconnect) action=start ;;
stopped) action=stop ;;
*) exit 0 ;;
esac
if [[ -z "${domain}" || "${domain}" == */* || ! -f "${conf_dir}/${domain}.conf" ]]; then
exit 0
fi
if ! instance="$(systemd-escape -- "${domain}")"; then
log "couldn't escape domain name '${domain}'"
exit 0
fi
unit="${unit_template}${instance}.service"
if ! systemctl "${action}" --no-block "${unit}"; then
log "systemctl ${action} ${unit} failed"
fi
exit 0
packaging/linux/qemu/sunshine-qemu-wait-vm +92 −0
@@ -1,0 +1,92 @@
#!/usr/bin/env bash
# Wait until the VM of a Sunshine config can be streamed; ExecStartPre of sunshine-qemu@.service.
#
# Sunshine checks QEMU's display once at startup (platf::init) and exits when it isn't there, so the
# unit waits for the display named by qemu_dbus_address in <config>:
# unix:path=<socket>,... the bus socket exists and QEMU owns org.qemu on it (gdbus)
# other bus addresses QEMU owns org.qemu on it (gdbus)
# qmp:<socket> the QMP socket exists
# libvirt:<domain>[?uri=URI] virsh reports the domain running (skipped without virsh)
# empty (session bus) not checked
#
# Usage: sunshine-qemu-wait-vm <config>
#
# Environment:
# SUNSHINE_QEMU_WAIT_TIMEOUT seconds to wait (default: 120)
# SUNSHINE_QEMU_WAIT_INTERVAL seconds between checks (default: 1)
set -euo pipefail
conf="${1:?usage: sunshine-qemu-wait-vm <config>}"
timeout_s="${SUNSHINE_QEMU_WAIT_TIMEOUT:-120}"
interval="${SUNSHINE_QEMU_WAIT_INTERVAL:-1}"
log() {
echo "sunshine-qemu-wait-vm: $*" >&2
}
if [[ ! -r "${conf}" ]]; then
log "can't read ${conf}"
exit 1
fi
# the last qemu_dbus_address line wins, as in Sunshine's config parser
address="$(sed -n -e 's/^[[:space:]]*qemu_dbus_address[[:space:]]*=[[:space:]]*//p' "${conf}" | tail -n 1 | sed -e 's/[[:space:]]*$//')"
bus_has_qemu() {
gdbus call --address "$1" --dest org.freedesktop.DBus --object-path /org/freedesktop/DBus \
--method org.freedesktop.DBus.NameHasOwner org.qemu 2> /dev/null | grep -q true
}
case "${address}" in
"")
log "qemu_dbus_address is empty (session bus); not waiting"
exit 0
;;
qmp:*)
socket="${address#qmp:}"
what="QMP socket ${socket}"
check() { [[ -S "${socket}" ]]; }
;;
libvirt:*)
target="${address#libvirt:}"
domain="${target%%\?uri=*}"
uri=""
[[ "${target}" == *"?uri="* ]] && uri="${target#*\?uri=}"
if ! command -v virsh > /dev/null; then
log "virsh isn't installed; not waiting for libvirt domain ${domain}"
exit 0
fi
what="libvirt domain ${domain}${uri:+ on ${uri}}"
check() { [[ "$(virsh ${uri:+-c "${uri}"} domstate "${domain}" 2> /dev/null | head -n 1)" == running ]]; }
;;
*)
if ! command -v gdbus > /dev/null; then
log "gdbus isn't installed; not waiting for ${address}"
exit 0
fi
socket=""
if [[ "${address}" =~ (^|[,;:])path=([^,;]*) ]]; then
socket="${BASH_REMATCH[2]}"
fi
what="org.qemu on ${address}"
check() { [[ -z "${socket}" || -S "${socket}" ]] && bus_has_qemu "${address}"; }
;;
esac
deadline=$((SECONDS + timeout_s))
logged=0
while true; do
if check; then
((logged)) && log "${what} is ready"
exit 0
fi
if ((SECONDS >= deadline)); then
log "timed out after ${timeout_s}s waiting for ${what}"
exit 1
fi
if ((!logged)); then
log "waiting for ${what}"
logged=1
fi
sleep "${interval}"
done
packaging/linux/qemu/sunshine-qemu.sysusers +5 −0
@@ -1,0 +1,5 @@
# systemd-sysusers(8) configuration for sunshine-qemu@.service; install as
# /usr/lib/sysusers.d/sunshine-qemu.conf
u sunshine-qemu - "Sunshine for QEMU VMs" /var/lib/sunshine-qemu -
m sunshine-qemu video
m sunshine-qemu render
packaging/linux/qemu/sunshine-qemu@.service +48 −0
@@ -1,0 +1,48 @@
# Sunshine for one QEMU VM: sunshine-qemu@<vm>.service reads /etc/sunshine-qemu/<vm>.conf.
# Install the helper scripts to /usr/lib/sunshine-qemu/ and create the user with sunshine-qemu.sysusers.
# Started at boot when enabled, or by the libvirt hook (libvirt-hook-qemu) when the domain starts.
# See docs/qemu.md.
[Unit]
Description=Sunshine game stream host for VM %I
Documentation=https://github.com/LizardByte/Sunshine/blob/master/docs/qemu.md
After=network-online.target avahi-daemon.service virtqemud.service libvirtd.service
Wants=network-online.target
ConditionPathExists=/etc/sunshine-qemu/%I.conf
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=simple
# A dedicated user. For qemu_dbus_address = libvirt:<domain> with p2p='yes' it needs the libvirt
# open_graphics permission (the libvirt group, or a polkit rule); for a bus address it must be allowed
# to connect to that bus (see docs/qemu.md).
User=sunshine-qemu
Group=sunshine-qemu
# GPU encoders (VAAPI render nodes, NVIDIA devices)
SupplementaryGroups=video render
# Per-VM state: relative paths in the config (file_state, credentials_file, pkey, cert, file_apps,
# log_path) and everything Sunshine keeps in its own directory land in /var/lib/sunshine-qemu/<vm>.
StateDirectory=sunshine-qemu/%i
StateDirectoryMode=0750
WorkingDirectory=%S/sunshine-qemu/%i
Environment=HOME=%S/sunshine-qemu/%i
Environment=XDG_CONFIG_HOME=%S/sunshine-qemu/%i
# platf::init() verifies QEMU's display once at startup, so wait until the VM is up.
ExecStartPre=/usr/lib/sunshine-qemu/sunshine-qemu-wait-vm /etc/sunshine-qemu/%I.conf
ExecStart=/usr/bin/sunshine /etc/sunshine-qemu/%I.conf
TimeoutStartSec=180
Restart=on-failure
RestartSec=5s
NoNewPrivileges=yes
PrivateTmp=yes
ProtectSystem=strict
ProtectHome=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 AF_NETLINK
LockPersonality=yes
[Install]
WantedBy=multi-user.target
packaging/linux/qemu/user/sunshine-qemu@.service +26 −0
@@ -1,0 +1,26 @@
# Rootless Sunshine for one QEMU VM, as a systemd user unit: sunshine-qemu@<vm>.service reads
# ~/.config/sunshine-qemu/<vm>.conf. For VMs started by the same user (raw QEMU, or libvirt's
# qemu:///session). Install to ~/.config/systemd/user/ or /usr/lib/systemd/user/. See docs/qemu.md.
[Unit]
Description=Sunshine game stream host for VM %I
Documentation=https://github.com/LizardByte/Sunshine/blob/master/docs/qemu.md
ConditionPathExists=%E/sunshine-qemu/%I.conf
StartLimitIntervalSec=300
StartLimitBurst=10
[Service]
Type=simple
# Per-VM state in ~/.local/state/sunshine-qemu/<vm>, so instances don't share ~/.config/sunshine.
StateDirectory=sunshine-qemu/%i
WorkingDirectory=%S/sunshine-qemu/%i
Environment=XDG_CONFIG_HOME=%S/sunshine-qemu/%i
# platf::init() verifies QEMU's display once at startup, so wait until the VM is up.
ExecStartPre=/usr/lib/sunshine-qemu/sunshine-qemu-wait-vm %E/sunshine-qemu/%I.conf
ExecStart=/usr/bin/sunshine %E/sunshine-qemu/%I.conf
TimeoutStartSec=180
Restart=on-failure
RestartSec=5s
[Install]
WantedBy=default.target
tests/packaging/linux/qemu/test_sunshine_qemu.sh +286 −0
@@ -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}"