ci: smoke-test built images; build on labeled PRs, weekly and on demand
smoke-test.sh checks an image from build-image.sh in two steps: - static, read-only: if the image declares SELinux, no kernel command line (/etc/kernel/cmdline, ESP and /boot loader entries) may carry selinux=0 and files must be labeled. This catches images built on a host with SELinux disabled, where anaconda inherits the host's selinux=0 and overrides the kickstart's `selinux --enforcing` (CI run 237); - boot: KVM with -snapshot, no network, waits for qemu-guest-agent to answer, so it does not depend on a serial console in the image. Verified on the runner: the run-237 image fails the static check, a fresh build passes both (agent answered after 27s). build-image.yaml now smoke-tests each edition before compressing it, and besides v*.*.* tags also runs: - on PRs into main labeled `build-image` (the review gate: Gitea does not let authors approve their own PRs), re-running on new pushes while the label stays, only for image-affecting paths, and not when an unrelated label is added; - weekly (Mondays 03:00), to catch upstream Fedora breakage; - via workflow_dispatch. Non-tag runs keep the images as 7-day artifacts. README updated, including the stale "every push to main" sentence. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c611bbf2c2
commit
f7b6623b25
Executable
+206
@@ -0,0 +1,206 @@
|
||||
#!/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:-<unset>} 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:-<none>}"
|
||||
[ -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
|
||||
Reference in New Issue
Block a user