mirror of
https://github.com/kuhyx/utils.git
synced 2026-11-10 21:07:15 +01:00
Much of testsAndMisc/linux_configuration cannot be tested on the host: 15 files use `chattr +i` (so a bad test is not simply revertable), the shutdown installers write units that power the machine off, and boot_recovery installs pacman hooks. The repo's own CI says as much -- its Ubuntu job runs only the side-effect-free tests, noting the rest "drive pacman, /etc, systemd or adb". vmbox runs them in a throwaway Arch guest and verifies the result from the HOST, which is the part that is not obvious: a successful shutdown kills the connection carrying the report, and ssh returns 255 for a clean poweroff, a crash and a dropped link alike. So the verdict comes from two artefacts that outlive the VM -- a QMP event log written by a recorder attached at launch (attach later and qemu has already exited), and the serial console log. Five outcomes are distinguished: clean poweroff (SHUTDOWN guest:true plus a serial tail reaching "reboot: Power down"), dirty stop, guest-reset (rebooted instead of powering off), GUEST_PANICKED, and hung. A POWERDOWN event proves nothing -- it means the request was delivered, not that anything acted on it. Reset is a qcow2 backing-file trick: the golden image is built once and sealed (chmod 444 plus a sha256 sidecar verified on every launch, since booting the base would silently invalidate every overlay), and each sandbox is a thin overlay, so reset is rm + recreate. No root anywhere -- user-mode networking with per-VM forwarded ssh ports, and a socket multicast segment for VM-to-VM. The guest clock is pinned and re-applied on every launch, because the shutdown installers gate on a 21:00-05:00 window; NTP is masked in the image or it silently undoes the pin. Repos are cloned from a read-only share scoped to an explicit directory, never $HOME, and credentials are sentinel values. `vm build` ends by booting a throwaway sandbox off the sealed image and refusing to ship if ssh does not answer. Several distinct faults produced an identical "boots fine, connection reset" symptom; checks inside the build VM only prove state at seal time, so the gate has to be an end-to-end boot. Tests: 8 bats cases over the verdict table and name/meta/serial handling; shellcheck and ruff clean; every file within the 250-line cap. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VdyMBGCrGVGDWtJ9xe48f3
105 lines
3.1 KiB
Python
Executable File
105 lines
3.1 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Persistent QMP event recorder for a vmbox sandbox.
|
|
|
|
Started at VM launch, before anything runs inside the guest, and appends every
|
|
QMP event to a JSONL file. This ordering is the whole point: a guest that
|
|
powers itself off kills QEMU, so a prober attached *after* the fact finds a
|
|
closed socket and no event at all. Recording from launch means the verdict is
|
|
read from a file that outlives the VM.
|
|
|
|
Usage: recorder.py <qmp.sock> <events.jsonl>
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import socket
|
|
import sys
|
|
import time
|
|
from typing import Any, TextIO
|
|
|
|
|
|
def connect(path: str, timeout: float = 30.0) -> socket.socket:
|
|
"""Connect to the QMP unix socket, retrying until QEMU creates it."""
|
|
deadline = time.time() + timeout
|
|
last_err: OSError | None = None
|
|
while time.time() < deadline:
|
|
sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
|
|
try:
|
|
sock.connect(path)
|
|
return sock
|
|
except OSError as exc:
|
|
last_err = exc
|
|
sock.close()
|
|
time.sleep(0.1)
|
|
raise SystemExit(f"recorder: could not connect to {path}: {last_err}")
|
|
|
|
|
|
def negotiate(stream: TextIO) -> None:
|
|
"""Complete the QMP capability handshake; events only flow afterwards."""
|
|
stream.readline() # greeting
|
|
stream.write(json.dumps({"execute": "qmp_capabilities"}) + "\n")
|
|
stream.flush()
|
|
for _ in range(20):
|
|
line = stream.readline()
|
|
if not line:
|
|
return
|
|
msg = json.loads(line)
|
|
if "return" in msg or "error" in msg:
|
|
return
|
|
|
|
|
|
def record(stream: TextIO, out: TextIO) -> None:
|
|
"""Append each event as one JSON line, flushing so readers see it at once."""
|
|
while True:
|
|
line = stream.readline()
|
|
if not line: # QEMU exited: the guest is gone, which is itself a signal.
|
|
_emit(out, {"event": "_RECORDER_EOF", "timestamp": _now()})
|
|
return
|
|
try:
|
|
msg: dict[str, Any] = json.loads(line)
|
|
except json.JSONDecodeError:
|
|
continue
|
|
if "event" in msg:
|
|
_emit(out, msg)
|
|
|
|
|
|
def _now() -> dict[str, int]:
|
|
now = time.time()
|
|
return {"seconds": int(now), "microseconds": int((now % 1) * 1_000_000)}
|
|
|
|
|
|
def _emit(out: TextIO, msg: dict[str, Any]) -> None:
|
|
out.write(json.dumps(msg) + "\n")
|
|
out.flush()
|
|
|
|
|
|
def main() -> int:
|
|
if len(sys.argv) != 3:
|
|
print(__doc__, file=sys.stderr)
|
|
return 2
|
|
qmp_path, events_path = sys.argv[1], sys.argv[2]
|
|
|
|
sock = connect(qmp_path)
|
|
# newline='\n' keeps QMP's line protocol intact regardless of locale.
|
|
stream = sock.makefile("rw", encoding="utf-8", newline="\n")
|
|
with open(events_path, "a", encoding="utf-8") as out:
|
|
negotiate(stream)
|
|
_emit(out, {"event": "_RECORDER_READY", "timestamp": _now()})
|
|
try:
|
|
record(stream, out)
|
|
except OSError as exc:
|
|
_emit(
|
|
out,
|
|
{
|
|
"event": "_RECORDER_ERROR",
|
|
"data": {"error": str(exc)},
|
|
"timestamp": _now(),
|
|
},
|
|
)
|
|
return 0
|
|
|
|
|
|
if __name__ == "__main__":
|
|
sys.exit(main())
|