feat(pi3 phase 2): bump rootfs to autodiscovery shims + E2E test

The Phase 2 shim originally hardcoded CHARDEV_PATH = /dev/vport0p2.
With QEMU 10 -M virt + virtio-blk-pci consuming slot 0, the proto
port actually lands at /dev/vport1p1 (or further). _transport.py
now walks /sys/class/virtio-ports/*/name looking for the literal
qemu name 'velxio-protocol' set by qemu_manager.

test/pi3_protocols/test_pi3_protocols.py end-to-end runner:
  1. Boot QEMU virt + pipe chardev (same args as production)
  2. Connect cons TCP socket + open both ends of the FIFO pair
  3. Wait for agetty autologin
  4. Run 'import RPi.GPIO; setup(17, OUT); output(17, 1)' in guest
  5. Read FIFO.out, assert 'GPIO_SETUP 17 out' + 'GPIO 17 1' appeared.

Catches: shim path resolution wrong, pipe chardev regression,
mux protocol drift, site-packages overlay broken.
This commit is contained in:
davidmonterocrespo24 2026-05-18 07:05:12 +02:00
parent de43ef47e4
commit eac845005c
2 changed files with 212 additions and 6 deletions

View File

@ -3,7 +3,7 @@
"version": 1,
"image_sets": {
"raspberry-pi-3-virt": {
"description": "Raspberry Pi 3 simulator on QEMU -M virt + cortex-a53. Velxio-built minimal ARM64 kernel + initramfs + Alpine-based rootfs. Replaces the broken raspi3b path (pl011 RX bug in QEMU 10 + kernel 6.12 see project/pi-emulation/decisions.md for the full trail). Used by app.services.qemu_manager.",
"description": "Raspberry Pi 3 simulator on QEMU -M virt + cortex-a53. Velxio-built minimal ARM64 kernel + initramfs + Alpine-based rootfs. Replaces the broken raspi3b path (pl011 RX bug in QEMU 10 + kernel 6.12 \u2014 see project/pi-emulation/decisions.md for the full trail). Used by app.services.qemu_manager.",
"images": [
{
"name": "velxio-kernel-arm64",
@ -22,19 +22,19 @@
{
"name": "velxio-pi-rootfs-arm64.ext4",
"asset_id": "velxio-pi-rootfs-arm64-zst",
"sha256": "f0ce53b933543dc071ddbf6a789f7045e603590000159951d886cf67c17c91ba",
"sha256": "6b471bdf2d078eae75e00249fa3d1d1c377a3801b205afebe1dd50dc9db5de07",
"size_bytes": 629145600,
"version": "2026.05+phase2-shims",
"version": "2026.05+phase2-shims-autodiscovery",
"compressed": {
"encoding": "zstd",
"sha256": "f93b7e97466dc97f01b98f987a60b0205558d74fd51c852771d4a7d2aa8106ce",
"size_bytes": 24733652
"sha256": "6c189066aa1796eb2344c4b140be2190e2e767467f1fd6a2db9d63f225fd4414",
"size_bytes": 24734406
}
}
]
},
"raspberry-pi-3": {
"description": "[DEPRECATED kept for one release to ease rollback] Original raspi3b setup. Replaced by 'raspberry-pi-3-virt' (see above). qemu_manager.py no longer references this entry; do not add new clients here.",
"description": "[DEPRECATED \u2014 kept for one release to ease rollback] Original raspi3b setup. Replaced by 'raspberry-pi-3-virt' (see above). qemu_manager.py no longer references this entry; do not add new clients here.",
"deprecated": true,
"images": [
{

View File

@ -0,0 +1,206 @@
#!/usr/bin/env python3
"""
Pi 3 Phase 2 protocol validation
================================
End-to-end test that mirrors the production qemu_manager.py setup
EXACTLY: -M virt, pipe chardev for the protocol channel, FIFOs on
disk. Boots the Pi, runs Python in the guest shell, asserts that
``import RPi.GPIO; GPIO.setup(17, OUT); GPIO.output(17, HIGH)``
produces the expected text frames on the host side of the proto
pipe.
What this catches:
- Shim site-packages overlay not active in the rootfs
- /sys/class/virtio-ports/*/name auto-discovery wrong
- pipe chardev fail (regressions vs QEMU 10 socket bug fix)
- qemu_manager's protocol mux not matching the shim wire format
Run:
docker cp this file into velxio-app:/tmp/
docker exec velxio-app python3 /tmp/test_pi3_protocols.py
Or, as part of a deploy gate, from the host via docker exec.
"""
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_S = 60
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_overlay() -> str:
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 _mk_proto_pipe() -> str:
base = tempfile.mktemp(prefix="velxio-pi-proto-test-")
for suffix in (".in", ".out"):
os.mkfifo(base + suffix, 0o600)
return base
def _qemu_argv(overlay: str, cons_port: int, proto_base: str) -> list[str]:
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},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={cons_port},server=on,wait=off",
"-device", "virtio-serial-pci,id=virtio-serial0",
"-device", "virtconsole,chardev=cons",
"-chardev", f"pipe,id=proto,path={proto_base}",
"-device", "virtserialport,chardev=proto,name=velxio-protocol",
"-append", "console=hvc0 root=/dev/vda rw panic=10",
]
def run() -> int:
for p in (KERNEL, INITRAMFS, ROOTFS):
if not p.exists():
print(f"FAIL: missing boot image: {p}", file=sys.stderr)
return 2
overlay = _make_overlay()
proto_base = _mk_proto_pipe()
cons_port = _find_free_port()
argv = _qemu_argv(overlay, cons_port, proto_base)
print("[test] launching:", " ".join(argv))
# Open both ends of each FIFO O_RDWR so we never hit EOF when the
# guest reopens its side. This mirrors qemu_manager._connect_gpio.
proto_in = os.open(proto_base + ".in", os.O_RDWR | os.O_NONBLOCK)
proto_out = os.open(proto_base + ".out", os.O_RDWR | os.O_NONBLOCK)
qemu = subprocess.Popen(
argv, stdout=subprocess.DEVNULL,
stderr=subprocess.PIPE, stdin=subprocess.DEVNULL,
)
try:
# Connect to user console socket.
sock = None
deadline = time.monotonic() + BOOT_TIMEOUT_S
while time.monotonic() < deadline:
try:
sock = socket.create_connection(("127.0.0.1", cons_port), timeout=5)
break
except (ConnectionRefusedError, OSError):
time.sleep(0.3)
if not sock:
print("FAIL: console TCP connection refused", file=sys.stderr)
return 1
sock.settimeout(2)
# Drain proto.out in a background-style poll.
proto_buf = bytearray()
def pull_proto() -> None:
try:
while True:
data = os.read(proto_out, 4096)
if not data:
break
proto_buf.extend(data)
except BlockingIOError:
return
except OSError:
return
# Walk through markers + send Python.
buf = bytearray()
saw_prompt = False
sent_test = False
while time.monotonic() < deadline:
pull_proto()
try:
chunk = sock.recv(4096)
except socket.timeout:
continue
if not chunk:
break
buf.extend(chunk)
if not saw_prompt and b"login on" in buf:
saw_prompt = True
print("[test] login reached, sending Python GPIO call")
time.sleep(2)
cmd = (b"python3 -c \"import RPi.GPIO as G; "
b"G.setmode(G.BCM); G.setup(17, G.OUT); "
b"G.output(17, 1); import time; time.sleep(0.3); "
b"print('SHIM_OK')\"\n")
sock.sendall(cmd)
sent_test = True
if sent_test and b"SHIM_OK" in buf:
# Give the proto pipe a moment to flush after Python exits
for _ in range(20):
pull_proto()
time.sleep(0.1)
print(f"[test] proto received {len(proto_buf)} bytes:")
txt = proto_buf.decode("ascii", "replace")
for ln in txt.strip().splitlines():
print(f" {ln}")
# Asserts
if "GPIO_SETUP 17 out" not in txt:
print("FAIL: GPIO_SETUP 17 out missing in proto frames",
file=sys.stderr)
return 1
if "GPIO 17 1" not in txt:
print("FAIL: GPIO 17 1 missing in proto frames",
file=sys.stderr)
return 1
print("[test] ✓ shim → proto pipeline works")
return 0
if sent_test and (b"Traceback" in buf or b"ModuleNotFoundError" in buf):
print("FAIL: guest python raised:", file=sys.stderr)
print(buf[-1500:].decode("utf-8", "replace"))
return 1
print(f"FAIL: timeout. saw_prompt={saw_prompt} sent_test={sent_test} "
f"buf_len={len(buf)}", file=sys.stderr)
print(buf[-1500:].decode("utf-8", "replace"), file=sys.stderr)
return 1
finally:
try:
qemu.terminate()
qemu.wait(timeout=5)
except subprocess.TimeoutExpired:
qemu.kill()
for fd in (proto_in, proto_out):
try: os.close(fd)
except OSError: pass
for suffix in (".in", ".out"):
try: os.unlink(proto_base + suffix)
except OSError: pass
try: os.unlink(overlay)
except OSError: pass
if __name__ == "__main__":
sys.exit(run())