feat(tools): commit the VM test harness + one-command installer regression

Promotes the throwaway harness that verified the installer into tools/:
- tools/vm/qmp.py        QMP keystroke injection / typing / quit
- tools/vm/vncshot.py    GL-safe screenshots via VNC readback (QMP
                         screendump shows "no surface" with virtio-vga-gl)
- tools/vm/gap-analysis.py  drv-graph diff that converged the offline
                         pin set; run it when offline installs build
                         from source
- tools/test-install.sh  the full offline regression: build ISO, boot
                         offline, unattended LUKS+swap install via a
                         config disk (typed long commands drop keys),
                         wait for poweroff, boot the installed disk,
                         screenshot the first boot for visual verdict

docs/TESTING.md §4 now points at the script.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Bernardo Magri
2026-06-12 08:05:13 +01:00
parent 7b5a22c800
commit a50e9793ea
6 changed files with 457 additions and 16 deletions

104
tools/vm/vncshot.py Executable file
View File

@@ -0,0 +1,104 @@
#!/usr/bin/env python3
"""Grab one full framebuffer from a no-auth VNC server and save as PNG.
Why VNC and not QMP screendump: with virtio-vga-gl the guest renders
into a GL scanout and screendump fails with "no surface"; QEMU's VNC
server reads the GL framebuffer back, so this always works. Pair the VM
with `-display egl-headless -vnc 127.0.0.1:17`.
Stdlib only (manual RFB handshake + raw-encoding framebuffer + PNG
writer) so it runs anywhere python3 exists.
Usage: vncshot.py <out.png> [host] [port] # default 127.0.0.1:5917
"""
import socket, struct, sys, zlib
def recv_exact(s, n):
buf = b""
while len(buf) < n:
chunk = s.recv(n - len(buf))
if not chunk:
raise EOFError("server closed")
buf += chunk
return buf
def png_write(path, w, h, rgb):
def chunk(tag, data):
c = struct.pack(">I", len(data)) + tag + data
return c + struct.pack(">I", zlib.crc32(tag + data) & 0xFFFFFFFF)
raw = b"".join(b"\x00" + rgb[y * w * 3:(y + 1) * w * 3] for y in range(h))
out = (b"\x89PNG\r\n\x1a\n"
+ chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0))
+ chunk(b"IDAT", zlib.compress(raw, 6))
+ chunk(b"IEND", b""))
with open(path, "wb") as f:
f.write(out)
def main():
out = sys.argv[1]
host = sys.argv[2] if len(sys.argv) > 2 else "127.0.0.1"
port = int(sys.argv[3]) if len(sys.argv) > 3 else 5917
s = socket.create_connection((host, port), timeout=30)
s.settimeout(30)
ver = recv_exact(s, 12) # "RFB 003.008\n"
s.sendall(b"RFB 003.008\n")
ntypes = recv_exact(s, 1)[0]
types = recv_exact(s, ntypes)
if 1 not in types:
sys.exit(f"no 'None' security type offered: {list(types)}")
s.sendall(b"\x01")
res = struct.unpack(">I", recv_exact(s, 4))[0]
if res != 0:
sys.exit("security handshake failed")
s.sendall(b"\x01") # ClientInit: shared
w, h = struct.unpack(">HH", recv_exact(s, 4))
recv_exact(s, 16) # server pixel format (ignored)
namelen = struct.unpack(">I", recv_exact(s, 4))[0]
recv_exact(s, namelen)
# SetPixelFormat: 32bpp truecolor, shifts r=16 g=8 b=0 (little-endian BGRX)
pf = struct.pack(">BBBBHHHBBBxxx", 32, 24, 0, 1, 255, 255, 255, 16, 8, 0)
s.sendall(b"\x00\x00\x00\x00" + pf)
s.sendall(struct.pack(">BxH i", 2, 1, 0)) # SetEncodings: Raw only
s.sendall(struct.pack(">BBHHHH", 3, 0, 0, 0, w, h)) # full update request
fb = bytearray(w * h * 4)
got = False
while not got:
mtype = recv_exact(s, 1)[0]
if mtype == 0: # FramebufferUpdate
recv_exact(s, 1)
nrects = struct.unpack(">H", recv_exact(s, 2))[0]
for _ in range(nrects):
x, y, rw, rh = struct.unpack(">HHHH", recv_exact(s, 8))
enc = struct.unpack(">i", recv_exact(s, 4))[0]
if enc == 0:
data = recv_exact(s, rw * rh * 4)
for row in range(rh):
off = ((y + row) * w + x) * 4
fb[off:off + rw * 4] = data[row * rw * 4:(row + 1) * rw * 4]
elif enc == -224: # LastRect
break
else:
sys.exit(f"unsupported encoding {enc}")
got = True
elif mtype == 2: # Bell
pass
elif mtype == 3: # ServerCutText
recv_exact(s, 3)
ln = struct.unpack(">I", recv_exact(s, 4))[0]
recv_exact(s, ln)
else:
sys.exit(f"unexpected server message {mtype}")
rgb = bytearray(w * h * 3)
for i in range(w * h):
rgb[i * 3 + 0] = fb[i * 4 + 2]
rgb[i * 3 + 1] = fb[i * 4 + 1]
rgb[i * 3 + 2] = fb[i * 4 + 0]
png_write(out, w, h, bytes(rgb))
print(f"saved {out} ({w}x{h})")
if __name__ == "__main__":
main()