feat(hack): add scripts to drive the dev environment headless

Start QEMU in the background with a QMP socket and console logs, take
screenshots, press keys and wait for serial output, so the graphical boot
menu can be driven without a terminal or a window. boot.sh gets opt-in
knobs for this and behaves as before otherwise.
This commit is contained in:
alex authored and Alex Zenla committed 2026-10-04 10:24:36 -07:00
1 parent 4e10d57143
commit c6524b7bed
12 files changed
+342 -2

No files matched your search

+16
View File
@@ -58,3 +58,19 @@ Build Sprout as OCI images using Docker, including a kernel, initramfs, xen, and
Boot Sprout's dev environment using QEMU for testing. This will let you test your changes in a real environment booting Boot Sprout's dev environment using QEMU for testing. This will let you test your changes in a real environment booting
Alpine Linux with an initramfs. Alpine Linux with an initramfs.
### ./hack/dev/run
Scripts to drive the dev environment without a terminal or a window, which suits automation and
coding agents. QEMU runs in the background with a QMP socket, and its consoles are written to
`target/run/<arch>/`. Only one run per architecture at a time, and the scripts need `python3` or `socat`.
- `start.sh [arch]`: Builds and starts QEMU headless. `SPROUT_CONFIG_NAME` defaults to `graphical`.
- `screenshot.sh [out.png]`: Saves a PNG of the display (QEMU 7.1 or newer) and prints the path.
- `key.sh <qcode>...`: Presses keys such as `down`, `up`, `ret` or `ctrl+alt+delete`.
- `wait-serial.sh <regex> [seconds]`: Waits for text on the firmware or Linux console.
- `qmp.sh <json>...`: Sends raw QMP commands.
- `status.sh` and `stop.sh`: Show the state of QEMU, and stop it.
- `smoke.sh [arch]`: Starts, drives the graphical menu, waits for the Linux shell and stops.
`boot.sh` supports this with `QEMU_HEADLESS`, `QEMU_QMP`, `QEMU_SERIAL_FILE`, `QEMU_CONSOLE_FILE` and `QEMU_EXTRA_ARGS`.
+20 -2
View File
@@ -9,7 +9,7 @@ if [ "${SKIP_BUILD}" != "1" ]; then
./hack/dev/build.sh "${TARGET_ARCH}" "${RUST_PROFILE}" ./hack/dev/build.sh "${TARGET_ARCH}" "${RUST_PROFILE}"
fi fi
clear [ -t 1 ] && clear
set -- set --
if [ "${TARGET_ARCH}" = "x86_64" ]; then if [ "${TARGET_ARCH}" = "x86_64" ]; then
@@ -40,7 +40,7 @@ set -- "${@}" -nodefaults -smp 2 -m 4096
if [ "${NO_GRAPHICAL}" = "1" ]; then if [ "${NO_GRAPHICAL}" = "1" ]; then
set -- "${@}" -nographic set -- "${@}" -nographic
else else
if [ "${GRAPHICAL_ONLY}" != "1" ]; then if [ "${GRAPHICAL_ONLY}" != "1" ] && [ "${QEMU_HEADLESS}" != "1" ]; then
if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then
set -- "${@}" -serial stdio set -- "${@}" -serial stdio
else else
@@ -51,6 +51,17 @@ else
fi fi
fi fi
# Headless keeps the virtual GPU, so screenshots still work, but shows no window. The firmware
# console and the Linux console go to files, as there is no terminal to attach to.
if [ "${QEMU_HEADLESS}" = "1" ]; then
set -- "${@}" \
-display none \
-serial "file:${QEMU_SERIAL_FILE:-/dev/null}" \
-device 'virtio-serial-pci,id=vs0' \
-chardev "file,id=file0,path=${QEMU_CONSOLE_FILE:-/dev/null}" \
-device 'virtconsole,chardev=file0,id=console0,name=alpine'
fi
if [ "${QEMU_LEGACY_VGA}" = "1" ]; then if [ "${QEMU_LEGACY_VGA}" = "1" ]; then
set -- "${@}" -vga std set -- "${@}" -vga std
else else
@@ -96,4 +107,11 @@ set -- "${@}" \
set -- "${@}" -name "sprout ${TARGET_ARCH}" set -- "${@}" -name "sprout ${TARGET_ARCH}"
if [ -n "${QEMU_QMP}" ]; then
set -- "${@}" -qmp "unix:${QEMU_QMP},server=on,wait=off"
fi
# shellcheck disable=SC2086
set -- "${@}" ${QEMU_EXTRA_ARGS}
exec "${@}" exec "${@}"
+32
View File
@@ -0,0 +1,32 @@
version = 1
[options]
default-entry = "kernel"
menu-style = "graphical"
menu-timeout = 120
[extractors.boot.filesystem-device-match]
has-item = "\\vmlinuz"
[actions.chainload-kernel]
chainload.path = "$boot\\vmlinuz"
chainload.options = ["console=hvc0", "overlaytmpfs=yes"]
chainload.linux-initrd = "$boot\\initramfs"
[entries.kernel]
title = "Boot Linux"
actions = ["chainload-kernel"]
[actions.chainload-shell]
chainload.path = "$boot\\EFI\\BOOT\\shell.efi"
[entries.shell]
title = "Boot Shell"
actions = ["chainload-shell"]
[actions.chainload-xen]
chainload.path = "$boot\\EFI\\BOOT\\xen.efi"
[entries.xen]
title = "Boot Xen"
actions = ["chainload-xen"]
+26
View File
@@ -0,0 +1,26 @@
#!/bin/sh
# Presses keys in the guest, one after another. Join keys with + to press them together.
# Usage: key.sh <qcode>... for example: key.sh down down ret or: key.sh ctrl+alt+delete
# Environment: KEY_DELAY is seconds between keys (default 0.3).
set -e
cd "$(dirname "${0}")/../../.." || exit 1
. "hack/dev/run/lib.sh"
require_running
[ "${#}" -gt 0 ] || die "usage: key.sh <qcode>..."
FIRST=1
for CHORD in "${@}"; do
[ "${FIRST}" = "1" ] || sleep "${KEY_DELAY:-0.3}"
FIRST=0
KEYS=""
OLD_IFS="${IFS}"
IFS="+"
for KEY in ${CHORD}; do
KEYS="${KEYS}${KEYS:+,}{\"type\":\"qcode\",\"data\":\"${KEY}\"}"
done
IFS="${OLD_IFS}"
./hack/dev/run/qmp.sh "{\"execute\":\"send-key\",\"arguments\":{\"keys\":[${KEYS}]}}" >/dev/null
done
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
# Shared by the other scripts in this directory: source it after changing to the repository root.
# shellcheck disable=SC2034
# common.sh reads the architecture from the first argument, which belongs to the calling script here.
load_common() {
set --
. "hack/common.sh"
}
load_common
RUN_DIR="$(pwd)/target/run/${TARGET_ARCH}"
SHOT_DIR="${RUN_DIR}/shots"
SERIAL_LOG="${RUN_DIR}/serial.log"
CONSOLE_LOG="${RUN_DIR}/console.log"
QEMU_LOG="${RUN_DIR}/qemu.log"
PID_FILE="${RUN_DIR}/qemu.pid"
# Unix socket paths are limited to about 104 bytes on macOS, which a deep worktree exceeds.
QMP_SOCK="${TMPDIR:-/tmp}"
QMP_SOCK="${QMP_SOCK%/}/sprout-${TARGET_ARCH}.qmp"
die() {
echo "ERROR: ${*}" >/dev/stderr
exit 1
}
is_running() {
[ -f "${PID_FILE}" ] && kill -0 "$(cat "${PID_FILE}")" 2>/dev/null
}
require_running() {
is_running || die "no ${TARGET_ARCH} QEMU is running, start one with hack/dev/run/start.sh"
}
+64
View File
@@ -0,0 +1,64 @@
#!/bin/sh
# Sends QMP commands to the running QEMU and prints each reply as a line of JSON.
# Usage: qmp.sh '{"execute":"query-status"}' ...
# Exits 1 if QEMU answers with an error. Environment: QMP_TIMEOUT is seconds per reply (default 30).
set -e
cd "$(dirname "${0}")/../../.." || exit 1
. "hack/dev/run/lib.sh"
require_running
[ "${#}" -gt 0 ] || die "usage: qmp.sh <json-command>..."
if command -v python3 >/dev/null 2>&1; then
exec python3 - "${QMP_SOCK}" "${@}" <<'PY'
import json, os, socket, sys
path, *commands = sys.argv[1:]
sock = socket.socket(socket.AF_UNIX)
sock.settimeout(float(os.environ.get("QMP_TIMEOUT", "30")))
sock.connect(path)
stream = sock.makefile("rw")
def call(command):
stream.write(command + "\n")
stream.flush()
while True:
line = stream.readline()
if not line:
sys.exit("qmp: connection closed")
message = json.loads(line)
if "return" in message or "error" in message:
return message
json.loads(stream.readline())
call('{"execute":"qmp_capabilities"}')
status = 0
for command in commands:
reply = call(command)
print(json.dumps(reply))
if "error" in reply:
status = 1
sys.exit(status)
PY
fi
command -v socat >/dev/null 2>&1 || die "python3 or socat is required"
# socat can't tell when a reply is complete, so it gets a fixed time to receive them.
OUTPUT="$(
{
printf '%s\n' '{"execute":"qmp_capabilities"}'
for COMMAND in "${@}"; do
printf '%s\n' "${COMMAND}"
done
sleep "${QMP_SETTLE:-1}"
} | socat -t1 - "UNIX-CONNECT:${QMP_SOCK}"
)"
echo "${OUTPUT}"
case "${OUTPUT}" in
*'"error"'*) exit 1 ;;
esac
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
# Saves a screenshot of the guest display as a PNG and prints its path, ready to be read as an image.
# Usage: screenshot.sh [out.png]
# Requires QEMU 7.1 or newer for PNG screendumps.
set -e
cd "$(dirname "${0}")/../../.." || exit 1
. "hack/dev/run/lib.sh"
require_running
OUT="${1:-${SHOT_DIR}/$(date +%H%M%S).png}"
case "${OUT}" in
/*) ;;
*) OUT="$(pwd)/${OUT}" ;;
esac
mkdir -p "$(dirname "${OUT}")"
./hack/dev/run/qmp.sh "{\"execute\":\"screendump\",\"arguments\":{\"filename\":\"${OUT}\",\"format\":\"png\"}}" >/dev/null
[ -s "${OUT}" ] || die "QEMU did not write ${OUT}"
echo "${OUT}"
+27
View File
@@ -0,0 +1,27 @@
#!/bin/sh
# Boots the graphical menu, moves the selection up and down, boots Linux and waits for its shell prompt.
# Usage: smoke.sh [arch]
# Screenshots land in target/run/<arch>/shots. The exit code is the result.
# Environment: SMOKE_MENU_DELAY is seconds to let the menu draw (default 10), SMOKE_BOOT_TIMEOUT is
# seconds to wait for the shell (default 180). The start.sh variables apply too.
set -e
cd "$(dirname "${0}")/../../.." || exit 1
[ -n "${1}" ] && TARGET_ARCH="${1}"
. "hack/dev/run/lib.sh"
export TARGET_ARCH
trap './hack/dev/run/stop.sh' EXIT
./hack/dev/run/start.sh "${TARGET_ARCH}"
sleep "${SMOKE_MENU_DELAY:-10}"
echo "menu: $(./hack/dev/run/screenshot.sh "${SHOT_DIR}/menu.png")"
# The default entry is the last one, so up is the key that visibly moves the selection.
./hack/dev/run/key.sh up
echo "up: $(./hack/dev/run/screenshot.sh "${SHOT_DIR}/menu-up.png")"
./hack/dev/run/key.sh down ret
./hack/dev/run/wait-serial.sh 'sprout:.*# ' "${SMOKE_BOOT_TIMEOUT:-180}"
echo "ok: reached a shell on ${TARGET_ARCH}"
+39
View File
@@ -0,0 +1,39 @@
#!/bin/sh
# Starts the dev environment in the background with no window, a QMP socket and serial logs.
# Usage: start.sh [arch]
# Environment: SKIP_BUILD=1 reuses target/final, SPROUT_CONFIG_NAME picks the config (default
# graphical), START_TIMEOUT is the seconds to wait for QEMU (default 900 to allow for a build).
set -e
cd "$(dirname "${0}")/../../.." || exit 1
[ -n "${1}" ] && TARGET_ARCH="${1}"
. "hack/dev/run/lib.sh"
is_running && die "a ${TARGET_ARCH} QEMU is already running, stop it with hack/dev/run/stop.sh"
rm -rf "${RUN_DIR}"
mkdir -p "${SHOT_DIR}"
rm -f "${QMP_SOCK}"
export SPROUT_CONFIG_NAME="${SPROUT_CONFIG_NAME:-graphical}"
export QEMU_HEADLESS=1
export QEMU_QMP="${QMP_SOCK}"
export QEMU_SERIAL_FILE="${SERIAL_LOG}"
export QEMU_CONSOLE_FILE="${CONSOLE_LOG}"
nohup ./hack/dev/boot.sh "${TARGET_ARCH}" </dev/null >"${QEMU_LOG}" 2>&1 &
echo "${!}" >"${PID_FILE}"
# boot.sh builds first and then execs QEMU, so the pid stays the same.
DEADLINE=$(($(date +%s) + ${START_TIMEOUT:-900}))
while [ ! -S "${QMP_SOCK}" ]; do
if ! is_running; then
tail -n 20 "${QEMU_LOG}" >/dev/stderr
die "QEMU exited before it was ready, see ${QEMU_LOG}"
fi
[ "$(date +%s)" -ge "${DEADLINE}" ] && die "timed out waiting for QEMU, see ${QEMU_LOG}"
sleep 1
done
echo "${RUN_DIR}"
+15
View File
@@ -0,0 +1,15 @@
#!/bin/sh
# Prints the state of QEMU: stopped, or the pid and the VM status such as running.
# Exits 1 when QEMU isn't running.
set -e
cd "$(dirname "${0}")/../../.." || exit 1
. "hack/dev/run/lib.sh"
if ! is_running; then
echo "stopped"
exit 1
fi
echo "pid $(cat "${PID_FILE}") $(./hack/dev/run/qmp.sh '{"execute":"query-status"}' 2>/dev/null || echo "no qmp")"
+19
View File
@@ -0,0 +1,19 @@
#!/bin/sh
# Stops QEMU and removes its socket. Safe to run when nothing is running.
set -e
cd "$(dirname "${0}")/../../.." || exit 1
. "hack/dev/run/lib.sh"
if is_running; then
PID="$(cat "${PID_FILE}")"
./hack/dev/run/qmp.sh '{"execute":"quit"}' >/dev/null 2>&1 || true
for _ in 1 2 3 4 5 6 7 8 9 10; do
is_running || break
sleep 0.5
done
is_running && kill "${PID}" 2>/dev/null || true
fi
rm -f "${QMP_SOCK}" "${PID_FILE}"
+29
View File
@@ -0,0 +1,29 @@
#!/bin/sh
# Waits for a regex to show up on the firmware or Linux console. Exits 1 on timeout or if QEMU exits.
# Usage: wait-serial.sh <regex> [timeout-seconds]
set -e
cd "$(dirname "${0}")/../../.." || exit 1
. "hack/dev/run/lib.sh"
[ -n "${1}" ] || die "usage: wait-serial.sh <regex> [timeout-seconds]"
DEADLINE=$(($(date +%s) + ${2:-60}))
while :; do
if cat "${SERIAL_LOG}" "${CONSOLE_LOG}" 2>/dev/null | grep -aEq -- "${1}"; then
exit 0
fi
if ! is_running; then
echo "QEMU exited while waiting for: ${1}" >/dev/stderr
break
fi
if [ "$(date +%s)" -ge "${DEADLINE}" ]; then
echo "timed out waiting for: ${1}" >/dev/stderr
break
fi
sleep 0.5
done
tail -n 15 "${SERIAL_LOG}" "${CONSOLE_LOG}" 2>/dev/null >/dev/stderr || true
exit 1