mirror of
https://github.com/edera-dev/sprout.git
synced 2026-11-04 18:42:21 +00:00
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:
1 parent
4e10d57143
commit
c6524b7bed
12 files changed
+342
-2
No files matched your search
@@ -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
@@ -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 "${@}"
|
||||||
@@ -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"]
|
||||||
Executable
+26
@@ -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
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
Executable
+64
@@ -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
|
||||||
Executable
+21
@@ -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}"
|
||||||
Executable
+27
@@ -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}"
|
||||||
Executable
+39
@@ -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}"
|
||||||
Executable
+15
@@ -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")"
|
||||||
Executable
+19
@@ -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}"
|
||||||
Executable
+29
@@ -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
|
||||||
Reference in new issue
Block a user