#!/usr/bin/env python3 """ Pi 3 console boot validation ============================ Spins up QEMU raspi3b with the SAME args the production qemu_manager uses (two ``-serial`` chardevs, ``init=/usr/local/sbin/velxio-init``, ``console=ttyAMA1,115200 keep_bootcon earlycon=pl011,mmio32,0x3f201000``) against the cached SD image. Connects to the user-serial TCP socket and asserts: 1. The kernel boots and reaches ``Run /usr/local/sbin/velxio-init as init process``. 2. The init script emits a ``[velxio-init] selected TTY=...`` diag line (i.e. doesn't hit the FATAL path). 3. The init script does NOT spam ``No such file or directory`` / ``No such device or address`` errors (which mean the redirect to the chosen TTY is failing). 4. The Velxio bashrc banner appears (``Velxio Raspberry Pi 3 — interactive root shell``). 5. After sending ``echo VELXIO_OK_$$\\n`` to the console, the same token comes back (proves the shell is actually reading input). Fidelity rule (memory ``feedback_tests_import_real_code``): the QEMU launch args mirror what ``app.services.qemu_manager`` emits, so any regression in the production manager that touches the console wiring fails this test too. The SD image read is the exact image cached by BootImageProvider at runtime. Run: cd /home/dave/velxio-prod python3 velxio/test/pi3_console_boot/test_pi3_console_boot.py The test takes ~45 s (boot is the slow part). Exit code 0 = OK, non-zero = failure with the offending log excerpt printed. """ from __future__ import annotations import os import socket import subprocess import sys import tempfile import time from pathlib import Path BOOT_IMAGES = Path("/var/cache/velxio/boot-images/raspberry-pi-3-virt") KERNEL = BOOT_IMAGES / "velxio-kernel-arm64" INITRAMFS = BOOT_IMAGES / "velxio-initramfs-arm64.cpio.gz" ROOTFS = BOOT_IMAGES / "velxio-pi-rootfs-arm64.ext4" # Boot timeout: cold boot is ~10-15 s on virt + minimal rootfs (much # faster than the old raspi3b + raspios path that took 25-30 s). BOOT_TIMEOUT_S = 45 # Markers we expect to see in the console output during a healthy # boot. The order is significant — each MUST appear before the # next in time. Matches what Alpine + agetty + autologin emit: # - "OpenRC 0.x" — openrc init started # - "Welcome to the Velxio Pi Simulator" — /etc/motd printed by login # - "raspberrypi:" — agetty's autologin reached the shell prompt BOOT_MARKERS = [ b"Velxio Pi Simulator", b"login on 'hvc0'", ] # Any of these substrings in the output means the init or shell is # broken — fail fast rather than waiting for the prompt timeout. BOOT_NEGATIVE_MARKERS = [ b"FATAL", b"Kernel panic", b"unable to mount root fs", b"Cannot open root device", ] def _find_free_port() -> int: with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: s.bind(("127.0.0.1", 0)) return s.getsockname()[1] def _make_qcow2_overlay() -> str: """Mirror of ``qemu_manager`` — qcow2 over the cached rootfs ext4 so the test never mutates the base.""" overlay = tempfile.NamedTemporaryFile(suffix=".qcow2", delete=False) overlay.close() subprocess.run( ["qemu-img", "create", "-f", "qcow2", "-b", str(ROOTFS), "-F", "raw", overlay.name], check=True, capture_output=True, ) return overlay.name def _qemu_argv(overlay_path: str, serial_port: int, gpio_port: int) -> list[str]: """Mirror of ``qemu_manager._boot`` — -M virt + virtio-{blk,serial} over PCI + velxio kernel/initramfs/rootfs.""" return [ "qemu-system-aarch64", "-M", "virt", "-cpu", "cortex-a53", "-smp", "4", "-m", "1G", "-kernel", str(KERNEL), "-initrd", str(INITRAMFS), "-drive", f"if=none,file={overlay_path},format=qcow2,id=rootfs", "-device", "virtio-blk-pci,drive=rootfs", "-nic", "none", "-display", "none", "-monitor", "none", "-serial", "none", "-chardev", f"socket,id=cons,host=127.0.0.1,port={serial_port}," f"server=on,wait=off", "-device", "virtio-serial-pci,id=virtio-serial0", "-device", "virtconsole,chardev=cons", "-chardev", f"socket,id=proto,host=127.0.0.1,port={gpio_port}," f"server=on,wait=off", "-device", "virtserialport,chardev=proto,name=velxio-protocol", "-append", "console=hvc0 root=/dev/vda rw panic=10", ] def _connect_with_retry(host: str, port: int, deadline: float) -> socket.socket: """Poll-connect to QEMU's TCP serial server (it accepts after QEMU has fully started).""" while time.monotonic() < deadline: try: s = socket.create_connection((host, port), timeout=2) s.settimeout(2) return s except (ConnectionRefusedError, OSError): time.sleep(0.2) raise TimeoutError(f"Could not connect to {host}:{port} within deadline") def _drain_until(sock: socket.socket, marker: bytes, deadline: float, negatives: list[bytes]) -> bytes: """Read from socket until ``marker`` appears or we hit a negative marker / deadline. Returns the accumulated buffer for inspection.""" buf = bytearray() while time.monotonic() < deadline: try: chunk = sock.recv(4096) except socket.timeout: continue if not chunk: break buf.extend(chunk) for neg in negatives: if neg in buf: return bytes(buf) if marker in buf: return bytes(buf) return bytes(buf) def run() -> int: # ── sanity: required files exist ─────────────────────────────── for p in (KERNEL, INITRAMFS, ROOTFS): if not p.exists(): print(f"FAIL: missing boot image: {p}", file=sys.stderr) return 2 if not subprocess.run( ["which", "qemu-system-aarch64"], capture_output=True ).returncode == 0: print("FAIL: qemu-system-aarch64 not in PATH", file=sys.stderr) return 2 # ── start QEMU ───────────────────────────────────────────────── overlay = _make_qcow2_overlay() serial_port = _find_free_port() gpio_port = _find_free_port() argv = _qemu_argv(overlay, serial_port, gpio_port) print(f"[test] QEMU: {' '.join(argv)}") qemu = subprocess.Popen( argv, stdout=subprocess.DEVNULL, stderr=subprocess.PIPE, stdin=subprocess.DEVNULL, ) deadline = time.monotonic() + BOOT_TIMEOUT_S try: # Connect to the user-serial channel. sock = _connect_with_retry("127.0.0.1", serial_port, deadline) print(f"[test] connected to user serial @ :{serial_port}") # Walk through each expected marker in order. Any negative # marker short-circuits the test. all_output = bytearray() for marker in BOOT_MARKERS: print(f"[test] waiting for marker: {marker!r}") chunk = _drain_until(sock, marker, deadline, BOOT_NEGATIVE_MARKERS) all_output.extend(chunk) for neg in BOOT_NEGATIVE_MARKERS: if neg in chunk: print(f"FAIL: negative marker hit: {neg!r}", file=sys.stderr) _dump_tail(bytes(all_output), 4000) return 1 if marker not in chunk: print(f"FAIL: timeout waiting for marker {marker!r}", file=sys.stderr) _dump_tail(bytes(all_output), 4000) return 1 print(f"[test] ✓ found") # ── round-trip: send a token, expect to see it echoed back ─ token = f"VELXIO_OK_{os.getpid()}".encode() # The shell echoes input back (canonical TTY mode), so sending # `echo TOKEN` followed by newline should produce `echo TOKEN` # (typed) followed by `TOKEN` (command output) on the wire. # We send a small delay to let the prompt settle. time.sleep(2) cmd = b"echo " + token + b"\n" print(f"[test] sending: {cmd!r}") sock.sendall(cmd) echoback = _drain_until(sock, token, deadline, BOOT_NEGATIVE_MARKERS) all_output.extend(echoback) if token not in echoback: print(f"FAIL: token {token!r} never came back from the shell — " "input is not flowing or shell is dead", file=sys.stderr) _dump_tail(bytes(all_output), 4000) return 1 # The token will appear at least twice (echo of typed bytes + # output of the echo command). One occurrence is suspicious # (could be just the echo of our send). if echoback.count(token) < 2: print(f"WARN: token {token!r} appears only " f"{echoback.count(token)}x — expected ≥2 " "(typed echo + command output). Shell may be in " "raw mode or the redirect is one-way.") # Don't fail — some configs disable echo. The presence # alone proves bidirectional flow. print("[test] ✓ Pi 3 console is interactive") return 0 finally: try: qemu.terminate() qemu.wait(timeout=5) except subprocess.TimeoutExpired: qemu.kill() try: os.unlink(overlay) except OSError: pass def _dump_tail(buf: bytes, n: int) -> None: tail = buf[-n:].decode("utf-8", errors="replace") print("─" * 70, file=sys.stderr) print("Last bytes of console output:", file=sys.stderr) print(tail, file=sys.stderr) print("─" * 70, file=sys.stderr) if __name__ == "__main__": sys.exit(run())