#!/bin/bash # Smoke-test a disk image produced by build-image.sh. # # Usage: ./smoke-test.sh [--timeout SECONDS] IMAGE # # 1. Static checks, read-only, without booting: if the image declares # SELinux (SELINUX= in /etc/selinux/config is not "disabled"), no boot # entry and not /etc/kernel/cmdline may carry selinux=0, and files must be # labeled. Catches builds on a host with SELinux disabled: anaconda then # inherits the host's selinux=0 and overrides the kickstart's # `selinux --enforcing` (see devices/runner.md in inventory-of-devices). # 2. Boot test: boots the image under KVM with -snapshot (the image is never # written) and no network, and waits for qemu-guest-agent to answer # guest-sync. The agent only starts once userspace is up, so this proves # firmware, bootloader, kernel, initramfs, root mount and systemd all work, # without depending on a serial console being configured in the image. # # Requires: root (loop devices, mount), qemu-system-x86_64 with /dev/kvm, # edk2-ovmf (UEFI images), e2fsprogs (debugfs), python3. The image must # include qemu-guest-agent (the guest-agents ingredient). set -euo pipefail TIMEOUT=300 while [[ $# -gt 0 ]]; do case "$1" in --timeout) TIMEOUT="$2" shift 2 ;; -h|--help) echo "Usage: $0 [--timeout SECONDS] IMAGE" exit 0 ;; *) break ;; esac done IMAGE="${1:?Usage: $0 [--timeout SECONDS] IMAGE}" [ -f "$IMAGE" ] || { echo "no such image: $IMAGE"; exit 1; } OVMF_CODE=/usr/share/edk2/ovmf/OVMF_CODE.fd OVMF_VARS=/usr/share/edk2/ovmf/OVMF_VARS.fd WORK="$(mktemp -d "${TMPDIR:-/tmp}/smoke-test.XXXXXX")" LOOP="" QEMU_PID="" cleanup() { [ -n "$QEMU_PID" ] && kill "$QEMU_PID" 2>/dev/null || true mountpoint -q "$WORK/esp" && umount "$WORK/esp" || true [ -n "$LOOP" ] && losetup -d "$LOOP" || true rm -rf "$WORK" } trap cleanup EXIT fail() { echo "FAIL: $*"; exit 1; } # --- 1. static checks --------------------------------------------------------- LOOP="$(losetup --read-only --partscan --find --show "$IMAGE")" udevadm settle # Filesystem labels come from the storage ingredients (root, boot); the ESP is # matched by partition type instead, as its FAT label ends up upper-cased. part_by_label() { lsblk -rno PATH,LABEL "$LOOP" | awk -v l="$1" '$2 == l { print $1; exit }'; } ROOT_PART="$(part_by_label root)" BOOT_PART="$(part_by_label boot)" ESP_PART="$(lsblk -rno PATH,PARTTYPE "$LOOP" | awk '$2 == "c12a7328-f81f-11d2-ba4b-00a0c93ec93b" { print $1; exit }')" [ -n "$ROOT_PART" ] || fail "no partition labeled 'root' in $IMAGE" rootcat() { debugfs -R "cat $1" "$ROOT_PART" 2>/dev/null; } selinux_mode="$(rootcat /etc/selinux/config | sed -n 's/^SELINUX=//p')" echo "SELINUX=${selinux_mode:-} in /etc/selinux/config" # Collect every kernel command line the image boots with. cmdlines="$WORK/cmdlines" : > "$cmdlines" { echo "== /etc/kernel/cmdline"; rootcat /etc/kernel/cmdline; } >> "$cmdlines" if [ -n "$ESP_PART" ]; then mkdir -p "$WORK/esp" mount -o ro "$ESP_PART" "$WORK/esp" for f in "$WORK"/esp/loader/entries/*.conf; do [ -e "$f" ] && { echo "== ESP ${f#"$WORK"/esp/}"; grep '^options' "$f"; } >> "$cmdlines" done fi if [ -n "$BOOT_PART" ]; then for f in $(debugfs -R "ls /loader/entries" "$BOOT_PART" 2>/dev/null | grep -o '[^ ]*\.conf'); do { echo "== /boot/loader/entries/$f"; debugfs -R "cat /loader/entries/$f" "$BOOT_PART" 2>/dev/null | grep '^options'; } >> "$cmdlines" done fi cat "$cmdlines" if [ -n "$selinux_mode" ] && [ "$selinux_mode" != disabled ]; then if grep -qw 'selinux=0' "$cmdlines"; then fail "image declares SELINUX=$selinux_mode but boots with selinux=0 (built on a host with SELinux disabled?)" fi for f in /etc/shadow /usr/bin/bash; do label="$(debugfs -R "ea_get $f security.selinux" "$ROOT_PART" 2>/dev/null | sed -n 's/.*= "\(.*\)\\000"$/\1/p')" echo "label $f: ${label:-}" [ -n "$label" ] || fail "$f has no SELinux label (built on a host with SELinux disabled?)" done fi echo "static checks: OK" mountpoint -q "$WORK/esp" && umount "$WORK/esp" losetup -d "$LOOP" LOOP="" # --- 2. boot test ------------------------------------------------------------- firmware=() if [ -n "$ESP_PART" ]; then cp "$OVMF_VARS" "$WORK/vars.fd" firmware=(-drive "if=pflash,format=raw,readonly=on,file=$OVMF_CODE" -drive "if=pflash,format=raw,file=$WORK/vars.fd") fi qemu-system-x86_64 \ -name smoke-test -machine q35,accel=kvm -cpu host -smp 2 -m 2048 \ "${firmware[@]}" \ -drive "file=$IMAGE,format=raw,if=virtio,snapshot=on" \ -nic none -display none -monitor none \ -serial "file:$WORK/serial.log" \ -chardev "socket,id=qga0,path=$WORK/qga.sock,server=on,wait=off" \ -device virtio-serial \ -device virtserialport,chardev=qga0,name=org.qemu.guest_agent.0 & QEMU_PID=$! echo "booting (timeout ${TIMEOUT}s), waiting for qemu-guest-agent..." if python3 - "$WORK/qga.sock" "$TIMEOUT" "$QEMU_PID" <<'EOF' import json, os, socket, sys, time path, timeout, qemu_pid = sys.argv[1], int(sys.argv[2]), int(sys.argv[3]) deadline = time.monotonic() + timeout def qemu_alive(): try: os.kill(qemu_pid, 0) return True except ProcessLookupError: return False while not os.path.exists(path): if not qemu_alive(): sys.exit("qemu exited before creating the guest agent socket") if time.monotonic() > deadline: sys.exit("qemu did not create the guest agent socket within %ds" % timeout) time.sleep(0.5) sock = socket.socket(socket.AF_UNIX) sock.connect(path) buf = b"" sync_id = 0 while time.monotonic() < deadline: if not qemu_alive(): sys.exit("qemu exited before the guest agent answered") # guest-sync with a fresh id flushes anything stale in the channel; the # agent echoes the id back once it runs. sync_id += 1 sock.sendall(json.dumps({"execute": "guest-sync", "arguments": {"id": sync_id}}).encode() + b"\n") sock.settimeout(5) try: while b"\n" not in buf: chunk = sock.recv(4096) if not chunk: break buf += chunk except socket.timeout: continue line, _, buf = buf.partition(b"\n") try: reply = json.loads(line) except ValueError: continue # Replies can lag behind: syncs sent while the agent was still starting # are answered in order, so accept any id we have sent. ret = reply.get("return") if isinstance(reply, dict) else None if isinstance(ret, int) and 1 <= ret <= sync_id: # Drain whatever else is queued before asking for osinfo. sock.settimeout(1) try: while True: chunk = sock.recv(4096) if not chunk: break except socket.timeout: pass buf = b"" sock.sendall(b'{"execute": "guest-get-osinfo"}\n') sock.settimeout(10) while b"\n" not in buf: buf += sock.recv(4096) info = json.loads(buf.partition(b"\n")[0]).get("return", {}) print("guest agent answered after %ds: %s, kernel %s" % (timeout - (deadline - time.monotonic()), info.get("pretty-name"), info.get("kernel-release"))) sys.exit(0) sys.exit("guest agent did not answer within %ds" % timeout) EOF then echo "boot test: OK" else echo "--- qemu-guest-agent on the serial console:" grep -a 'qemu-guest-agent\|Guest Agent' "$WORK/serial.log" 2>/dev/null || echo "(no mention)" echo "--- last 40 lines of the serial console:" tail -40 "$WORK/serial.log" 2>/dev/null || true fail "boot test" fi