From c6524b7bed5cd3f2d3d4292265d191e5f2258174 Mon Sep 17 00:00:00 2001 From: Alex Zenla Date: Sun, 4 Oct 2026 10:21:13 -0700 Subject: [PATCH] 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. --- DEVELOPMENT.md | 16 +++++++ hack/dev/boot.sh | 22 ++++++++- hack/dev/configs/graphical.sprout.toml | 32 +++++++++++++ hack/dev/run/key.sh | 26 +++++++++++ hack/dev/run/lib.sh | 34 ++++++++++++++ hack/dev/run/qmp.sh | 64 ++++++++++++++++++++++++++ hack/dev/run/screenshot.sh | 21 +++++++++ hack/dev/run/smoke.sh | 27 +++++++++++ hack/dev/run/start.sh | 39 ++++++++++++++++ hack/dev/run/status.sh | 15 ++++++ hack/dev/run/stop.sh | 19 ++++++++ hack/dev/run/wait-serial.sh | 29 ++++++++++++ 12 files changed, 342 insertions(+), 2 deletions(-) create mode 100644 hack/dev/configs/graphical.sprout.toml create mode 100755 hack/dev/run/key.sh create mode 100644 hack/dev/run/lib.sh create mode 100755 hack/dev/run/qmp.sh create mode 100755 hack/dev/run/screenshot.sh create mode 100755 hack/dev/run/smoke.sh create mode 100755 hack/dev/run/start.sh create mode 100755 hack/dev/run/status.sh create mode 100755 hack/dev/run/stop.sh create mode 100755 hack/dev/run/wait-serial.sh diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index b994aa3..0b64a78 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -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 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//`. 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 ...`: Presses keys such as `down`, `up`, `ret` or `ctrl+alt+delete`. +- `wait-serial.sh [seconds]`: Waits for text on the firmware or Linux console. +- `qmp.sh ...`: 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`. diff --git a/hack/dev/boot.sh b/hack/dev/boot.sh index aa00ab2..22d2946 100755 --- a/hack/dev/boot.sh +++ b/hack/dev/boot.sh @@ -9,7 +9,7 @@ if [ "${SKIP_BUILD}" != "1" ]; then ./hack/dev/build.sh "${TARGET_ARCH}" "${RUST_PROFILE}" fi -clear +[ -t 1 ] && clear set -- if [ "${TARGET_ARCH}" = "x86_64" ]; then @@ -40,7 +40,7 @@ set -- "${@}" -nodefaults -smp 2 -m 4096 if [ "${NO_GRAPHICAL}" = "1" ]; then set -- "${@}" -nographic else - if [ "${GRAPHICAL_ONLY}" != "1" ]; then + if [ "${GRAPHICAL_ONLY}" != "1" ] && [ "${QEMU_HEADLESS}" != "1" ]; then if [ "${QEMU_LEGACY_SERIAL}" = "1" ]; then set -- "${@}" -serial stdio else @@ -51,6 +51,17 @@ else 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 set -- "${@}" -vga std else @@ -96,4 +107,11 @@ set -- "${@}" \ 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 "${@}" diff --git a/hack/dev/configs/graphical.sprout.toml b/hack/dev/configs/graphical.sprout.toml new file mode 100644 index 0000000..8ae9c03 --- /dev/null +++ b/hack/dev/configs/graphical.sprout.toml @@ -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"] diff --git a/hack/dev/run/key.sh b/hack/dev/run/key.sh new file mode 100755 index 0000000..afc91a5 --- /dev/null +++ b/hack/dev/run/key.sh @@ -0,0 +1,26 @@ +#!/bin/sh +# Presses keys in the guest, one after another. Join keys with + to press them together. +# Usage: key.sh ... 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 ..." + +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 diff --git a/hack/dev/run/lib.sh b/hack/dev/run/lib.sh new file mode 100644 index 0000000..5394168 --- /dev/null +++ b/hack/dev/run/lib.sh @@ -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" +} diff --git a/hack/dev/run/qmp.sh b/hack/dev/run/qmp.sh new file mode 100755 index 0000000..c8d2917 --- /dev/null +++ b/hack/dev/run/qmp.sh @@ -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 ..." + +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 diff --git a/hack/dev/run/screenshot.sh b/hack/dev/run/screenshot.sh new file mode 100755 index 0000000..1e88f0f --- /dev/null +++ b/hack/dev/run/screenshot.sh @@ -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}" diff --git a/hack/dev/run/smoke.sh b/hack/dev/run/smoke.sh new file mode 100755 index 0000000..89e77c4 --- /dev/null +++ b/hack/dev/run/smoke.sh @@ -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//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}" diff --git a/hack/dev/run/start.sh b/hack/dev/run/start.sh new file mode 100755 index 0000000..e1ff73b --- /dev/null +++ b/hack/dev/run/start.sh @@ -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}" "${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}" diff --git a/hack/dev/run/status.sh b/hack/dev/run/status.sh new file mode 100755 index 0000000..3f76f82 --- /dev/null +++ b/hack/dev/run/status.sh @@ -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")" diff --git a/hack/dev/run/stop.sh b/hack/dev/run/stop.sh new file mode 100755 index 0000000..8f595ff --- /dev/null +++ b/hack/dev/run/stop.sh @@ -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}" diff --git a/hack/dev/run/wait-serial.sh b/hack/dev/run/wait-serial.sh new file mode 100755 index 0000000..bc77675 --- /dev/null +++ b/hack/dev/run/wait-serial.sh @@ -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 [timeout-seconds] +set -e + +cd "$(dirname "${0}")/../../.." || exit 1 + +. "hack/dev/run/lib.sh" + +[ -n "${1}" ] || die "usage: wait-serial.sh [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