test-qemu.py (6990 bytes)
1 #!/usr/bin/env python3 2 """Unattended QEMU test of waybase.img for one base. 3 4 Usage: test-qemu.py IMG WORKDIR BASE "COMPOSITORS" [encrypt|plain] 5 Stdlib only. Env: QEMU, QEMU_IMG, OVMF (UEFI firmware), MEM (MiB, 2048), 6 ACCEL (kvm|tcg), CPU (default Nehalem = x86-64-v2, so no binary may need AVX). 7 8 1. UEFI boot of the live image as a USB stick; install.sh with an answers file. 9 2. BIOS boot of the installed disk to the passphrase or login prompt. 10 3. UEFI boot of the installed disk: unlock, log in as the user, then check 11 `nix --version`, the nix daemon, and that each compositor starts headless 12 and creates a Wayland socket. 13 Exit 0 only if every check passes. The serial log goes to WORKDIR/BASE.log. 14 """ 15 import os, re, socket, subprocess, sys, time 16 17 img, work, base, comps = sys.argv[1], sys.argv[2], sys.argv[3], sys.argv[4] 18 encrypt = (sys.argv[5] if len(sys.argv) > 5 else "encrypt") == "encrypt" 19 disk = os.path.join(work, f"{base}.qcow2") 20 QEMU = os.environ.get("QEMU", "qemu-system-x86_64") 21 OVMF = os.environ.get("OVMF", "/usr/share/ovmf/bios.bin") 22 ACCEL, CPU = os.environ.get("ACCEL", "kvm"), os.environ.get("CPU", "Nehalem") 23 LOG = open(os.path.join(work, f"{base}.log"), "a", encoding="utf-8", errors="replace") 24 PW = {"luks": "test-luks", "user": "test-user", "root": "test-root"} 25 26 27 def note(s): 28 LOG.write(f"\n##### {s}\n"); LOG.flush(); print(f"##### {base}: {s}", flush=True) 29 30 31 class VM: 32 def __init__(self, uefi, live, port=45560): 33 args = [QEMU, "-accel", ACCEL, "-cpu", CPU, "-m", os.environ.get("MEM", "2048"), "-smp", "2", 34 "-display", "none", "-monitor", "none", 35 "-chardev", f"socket,id=s0,host=127.0.0.1,port={port},server=on,wait=on", 36 "-serial", "chardev:s0", "-nic", "user,model=virtio-net-pci", 37 "-drive", f"file={disk},if=virtio,format=qcow2,cache=unsafe"] 38 if uefi: 39 args += ["-bios", OVMF] 40 if live: 41 args += ["-device", "qemu-xhci", "-drive", f"if=none,id=usb,file={img},format=raw,readonly=on", 42 "-device", "usb-storage,drive=usb,bootindex=0"] 43 self.p = subprocess.Popen(args) 44 for _ in range(150): 45 try: 46 self.s = socket.create_connection(("127.0.0.1", port)); break 47 except OSError: 48 time.sleep(0.2) 49 else: 50 sys.exit("FAIL: no QEMU serial socket") 51 self.buf = "" 52 53 def expect(self, pat, timeout): 54 end = time.time() + timeout 55 while True: 56 m = re.search(pat, self.buf, re.S) 57 if m: 58 self.buf = self.buf[m.end():] 59 return m 60 if time.time() > end or self.p.poll() is not None: 61 self.kill() 62 sys.exit(f"FAIL: {base}: timeout or QEMU exit waiting for {pat!r}") 63 self.s.settimeout(5) 64 try: 65 d = self.s.recv(65536) 66 except socket.timeout: 67 continue 68 if not d: 69 sys.exit(f"FAIL: {base}: serial closed waiting for {pat!r}") 70 t = d.decode("utf-8", "replace") 71 if "\x1b[6n" in t: # busybox ash asks for the cursor position 72 self.s.sendall(b"\x1b[50;200R") 73 LOG.write(t); LOG.flush() 74 self.buf = (self.buf + t)[-200000:] 75 76 def send(self, line): 77 self.s.sendall((line + "\r").encode()) 78 79 def run(self, script, timeout=600): 80 """Run script with sh and return the output between markers.""" 81 self.buf = "" 82 self.send("sh <<'EOS'\recho @@BEG\"\"@@\r" + script.strip().replace("\n", "\r") + "\recho @@END\"\"@@\rEOS") 83 return self.expect(r"@@BEG@@(.*?)@@END@@", timeout).group(1) 84 85 def kill(self): 86 self.p.kill(); self.p.wait() 87 88 89 def login(vm, user, pw): 90 vm.expect(r"login: ", 1800) 91 vm.send(user) 92 vm.expect("Password:", 60) 93 vm.send(pw) 94 time.sleep(10) 95 96 97 fails = [] 98 def check(name, ok, detail=""): 99 note(f"CHECK {name}: {'ok' if ok else 'FAIL'} {detail}") 100 ok or fails.append(name) 101 102 103 if os.path.exists(disk): 104 os.remove(disk) 105 subprocess.run([os.environ.get("QEMU_IMG", "qemu-img"), "create", "-q", "-f", "qcow2", disk, "32G"], check=True) 106 note(f"compositors={comps} encrypt={encrypt} accel={ACCEL} cpu={CPU}") 107 108 note("1/3 UEFI live boot + unattended install") 109 t0 = time.time() 110 vm = VM(uefi=True, live=True) 111 vm.expect("login:", 900); vm.send("root"); time.sleep(5) 112 answers = (f"BASE={base}\nCOMPOSITOR='{comps}'\nDISK=/dev/vda\nYES=1\nSERIAL=1\nFIRMWARE=no\n" 113 f"FIRMWARE_TAG=20260916\nFIRMWARE_LIST=$D/firmware.example\n" 114 f"ENCRYPT={'yes' if encrypt else 'no'}\nLUKS_PASS={PW['luks']}\nUSERNAME=tester\n" 115 f"USER_PASS={PW['user']}\nROOT_PASS={PW['root']}\n") 116 vm.run(f"cat > /tmp/answers <<'EOA'\n{answers}EOA") 117 vm.send("sh /media/*/waybase/install.sh /tmp/answers; echo INSTALL_RC=$?; sleep 2; poweroff") 118 m = vm.expect(r"INSTALL_RC=(\d+)", 4 * 3600) 119 vm.p.wait(300) 120 note(f"install took {int(time.time() - t0)} s") 121 if int(m.group(1)): 122 sys.exit(f"FAIL: {base}: install.sh rc={m.group(1)}") 123 124 prompt = "Enter passphrase|Passphrase|passphrase for" if encrypt else "login: " 125 note("2/3 BIOS boot of the installed disk") 126 vm = VM(uefi=False, live=False); vm.expect(prompt, 900); vm.kill() 127 check("BIOS boot reaches " + ("passphrase" if encrypt else "login"), True) 128 129 note("3/3 UEFI boot of the installed disk + checks") 130 vm = VM(uefi=True, live=False) 131 if encrypt: 132 vm.expect(prompt, 900); time.sleep(2); vm.send(PW["luks"]) 133 login(vm, "tester", PW["user"]) 134 r = vm.run(r""" 135 . /etc/profile >/dev/null 2>&1 136 nix --version 2>&1 | head -1 | sed 's/^/NIXVER /' 137 nix store info --store daemon >/tmp/ns.log 2>&1 && echo NIXDAEMON_OK || tail -3 /tmp/ns.log 138 for b in foot fuzzel pipewire wireplumber waybase-session; do command -v $b >/dev/null || echo MISSING:$b; done 139 echo FROMNIX $(cat /etc/waybase-from-nix) 140 test -s /lib/firmware/i915/kbl_dmc_ver1_04.bin && echo FW_OK 141 for c in %s; do 142 d=$(mktemp -d); chmod 700 $d 143 XDG_RUNTIME_DIR=$d WLR_BACKENDS=headless WLR_RENDERER=pixman WLR_LIBINPUT_NO_DEVICES=1 $c >/tmp/$c.log 2>&1 & 144 pid=$!; i=0 145 while [ $i -lt 30 ] && ! ls $d | grep -q '^wayland-[0-9]*$'; do sleep 1; i=$((i+1)); done 146 if ls $d | grep -q '^wayland-[0-9]*$'; then echo UP:$c; else echo DOWN:$c; tail -5 /tmp/$c.log; fi 147 kill $pid 2>/dev/null; sleep 1 148 done 149 """ % comps, 900) 150 check("nix --version", "NIXVER nix (Nix)" in r, (re.search(r"NIXVER (.*)", r) or [None, ""])[1].strip()) 151 check("nix daemon reachable", "NIXDAEMON_OK" in r) 152 check("pinned firmware installed", "FW_OK" in r) 153 check("session programs", "MISSING:" not in r, " ".join(re.findall(r"MISSING:\S+", r))) 154 note("supplied by Nix: " + (re.search(r"FROMNIX(.*)", r) or [None, ""])[1].strip()) 155 for c in comps.split(): 156 check(f"{c} starts headless", f"UP:{c}" in r) 157 vm.send("exit"); time.sleep(3) 158 vm.kill() 159 note(f"QEMU TEST {base} " + ("PASSED" if not fails else "FAILED: " + ", ".join(fails))) 160 sys.exit(1 if fails else 0)