From 962cdb698114bb99f02473aae468e5ed55627990 Mon Sep 17 00:00:00 2001 From: alex Date: Tue, 8 Sep 2026 16:53:04 -0400 Subject: [PATCH] tools: live screen wall + contact sheet for the five lab guests The lab went from one Windows guest to five and there was no way to see what they were all doing without issuing a QEMU screendump per guest by hand. - tools/vmwatch.py: always-on HTTP service serving an auto-refreshing wall of live guest screens. Runs on spicy as vmwatch.service; browse it at http://192.168.3.201:8140/. Click a tile for that guest full size. Guests are discovered from /etc/pve/qemu-server by matching sots-re, so clones appear and vanish on their own. A stopped, paused or unreachable guest gets a labelled placeholder tile carrying the monitor's own error, never a broken image or a 500. Python 3 stdlib only. - tools/vmwatch-install.sh: install/update/uninstall the unit on the host. - tools/vmshot.py: one-shot contact sheet, and --one for a full-size grab. Pulls frames from the vmwatch service when it is up (0.5s) and falls back to ssh + qm monitor when it is not (4s). - guides/lab-screen-wall.md: how to use both, and why. Capture goes over each guest's QMP socket rather than forking qm: qm is a Perl program, and one fork per guest per tick cost ~90% of a host core and a 728 MB cgroup peak. Direct QMP is 0.33 CPU-seconds per 88s and 23 MB RSS. QEMU 11 here dumps PNG natively; the fallback PPM encoder was verified pixel-identical to QEMU's own on a real framebuffer. Read-only throughout: screendump does not perturb the guest (method-rule 19), so reading VM 140's screen is not an experiment and does not take its lock. --- guides/lab-screen-wall.md | 63 ++++ tools/vmshot.py | 444 ++++++++++++++++++++++++++ tools/vmwatch-install.sh | 60 ++++ tools/vmwatch.py | 637 ++++++++++++++++++++++++++++++++++++++ 4 files changed, 1204 insertions(+) create mode 100644 guides/lab-screen-wall.md create mode 100755 tools/vmshot.py create mode 100755 tools/vmwatch-install.sh create mode 100755 tools/vmwatch.py diff --git a/guides/lab-screen-wall.md b/guides/lab-screen-wall.md new file mode 100644 index 0000000..9855e2e --- /dev/null +++ b/guides/lab-screen-wall.md @@ -0,0 +1,63 @@ +# Watching the lab guests + +The lab is five Windows guests now. This is how to see all of them at once. + +## The live wall + + + +Leave the tab open. Tiles refresh every 5 s; click a tile for that guest full size. +Each tile carries the VM id, name, status and the capture timestamp. A guest that is +stopped, paused or unreachable shows a labelled `NO SIGNAL` tile with the monitor's +own error text, never a broken image. + +Served by `vmwatch.service` on **spicy** (`/opt/vmwatch/vmwatch.py`, systemd, enabled +at boot). Source of truth is `tools/vmwatch.py` in this repo; redeploy with: + + tools/vmwatch-install.sh spicy # install or update + tools/vmwatch-install.sh spicy --uninstall + +Nothing was installed on spicy beyond that file and its unit — the service is Python 3 +standard library only (spicy has no ImageMagick, no netpbm, no Pillow). + +## The one-shot contact sheet + + tools/vmshot.py # all guests -> dumps/vmshot/sheet-.png + latest.png + tools/vmshot.py --one 140 # one guest, full size + tools/vmshot.py 144 145 --cols 2 + tools/vmshot.py --ssh # bypass the service, capture over SSH + +`vmshot` pulls frames from the vmwatch service when it is reachable (~0.5 s, no extra +load on the guests) and falls back to SSH + `qm monitor` when it is not (~4 s). Output +lands in `dumps/vmshot/`, which is gitignored. + +## Why this mechanism + +Both tools capture with QEMU `screendump`, reached over each guest's +`/var/run/qemu-server/.qmp` socket — the same socket and command `qm monitor` uses. + +* It needs **no guest agent, no guest network and nothing installed in the guest**. +* It does **not perturb the guest**. That is the point (method-rule 19: an instrument + that perturbs the thing it measures has already cost this campaign once). Reading + VM 140's screen is therefore *not* an experiment and does **not** take 140's + exclusivity lock. +* The campaign board already records that `qm monitor` screendump is more reliable + than the in-guest click helper's `shot`. + +## Two things worth knowing + +**Do not fork `qm` in a loop.** The first version of vmwatch shelled out to +`qm monitor` once per guest per 5 s tick. `qm` is a Perl program: that cost **~90 % of +a host core and a 728 MB cgroup peak**. Talking to the QMP socket directly — same +socket, same command, no fork — brought it to **0.33 CPU-seconds per 88 s and 23 MB +RSS**, roughly a 300× reduction. The fleet list comes from `/etc/pve/qemu-server/*.conf` +plus `query-status` for the same reason. `qm` remains the fallback path only. + +**QEMU 11 on spicy dumps PNG natively** (`screendump -f png`), so no PPM +conversion is needed. `vmwatch.ppm_to_png` exists as a fallback for an older QEMU and +was checked against QEMU's own encoder on a real 1024×768 framebuffer: pixel-identical. + +Guests are discovered dynamically by matching `sots-re` in the guest name, so clones +added or destroyed later appear and vanish on their own — nothing to edit. + +Both tools are strictly read-only. Neither starts, stops, resets nor reconfigures a VM. diff --git a/tools/vmshot.py b/tools/vmshot.py new file mode 100755 index 0000000..6a12e05 --- /dev/null +++ b/tools/vmshot.py @@ -0,0 +1,444 @@ +#!/usr/bin/env -S uv run --quiet --with pillow python3 +"""vmshot -- one-shot screenshot contact sheet for the SOTS lab Windows guests. + +Why this exists +--------------- +The lab went from one Windows guest (VM 140) to five (140, 141, 144, 145, 146). +There was no way to see what they were all doing without issuing a QEMU +``screendump`` per guest by hand. This grabs every guest in parallel and lays +them out on a single labelled contact sheet. + +Mechanism +--------- +``qm monitor <<< "screendump -f png"`` on the Proxmox host. This +reads the guest framebuffer straight out of QEMU: + +* no guest agent, no guest network, nothing installed inside the guest; +* it does not perturb the guest at all -- relevant to method-rule 19, an + instrument that perturbs the thing it measures. Reading VM 140's screen is + therefore *not* an experiment and does not take VM 140's exclusivity lock; +* the campaign board already records that ``qm monitor`` screendump is more + reliable than the in-guest click helper's ``shot``. + +spicy runs pve-manager 9.2 / QEMU 11, whose ``screendump`` takes ``-f png`` +natively, so no PPM conversion and no netpbm/ImageMagick is needed on the host +(none is installed there, and this tool installs nothing). The PNGs are written +to a per-run temp dir on the host, streamed back inside a base64 tar in the same +SSH round trip, and the temp dir is removed before the SSH call returns -- so +nothing is left behind on spicy even if this script is killed. + +Relationship to vmwatch +----------------------- +``tools/vmwatch.py`` is the live version of this: an always-on service on spicy +serving an auto-refreshing wall at http://192.168.3.201:8140/. When that +service is reachable this script pulls its already-captured frames over HTTP +instead of opening its own SSH session -- same frames, no extra load on the +guests, and it works from anywhere on the LAN without SSH. If the service is +down it falls back to the SSH + ``qm monitor`` path described below, so the CLI +never depends on the service being up. + +Usage +----- + tools/vmshot.py # contact sheet of every sots-* guest + tools/vmshot.py --open # ... and open it in the default viewer + tools/vmshot.py 140 141 # only these ids + tools/vmshot.py --one 140 # full-size single guest, no sheet + tools/vmshot.py --cols 3 --width 900 + +Output lands in ``~/sots-re/dumps/vmshot/`` (``dumps/`` is gitignored). + +A guest that is stopped, paused, or whose screendump fails gets a labelled +placeholder tile with the host-side error rather than aborting the sheet. +""" + +from __future__ import annotations + +import argparse +import base64 +import io +import json +import os +import re +import subprocess +import urllib.error +import urllib.request +import sys +import tarfile +import time +from datetime import datetime +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +HOST = "spicy" +SERVICE = os.environ.get("VMWATCH_URL", "http://192.168.3.201:8140") +OUT_DIR = Path.home() / "sots-re" / "dumps" / "vmshot" +FLEET_PATTERN = re.compile(r"sots", re.I) +FONT_PATH = "/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf" +FONT_BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf" + +# Tile chrome +LABEL_H = 34 +PAD = 10 +BG = (24, 26, 30) +LABEL_BG = (38, 42, 50) +LABEL_BG_BAD = (74, 34, 34) +FG = (232, 234, 238) +FG_DIM = (150, 156, 166) +BORDER = (60, 66, 78) +PLACEHOLDER_BG = (44, 30, 30) + + +def ssh(cmd: str, timeout: int = 90) -> subprocess.CompletedProcess: + return subprocess.run( + ["ssh", "-o", "BatchMode=yes", HOST, cmd], + capture_output=True, + timeout=timeout, + ) + + +def service_state(timeout: float = 3.0) -> list[dict] | None: + """Fleet state from a reachable vmwatch service, or None if it is not up.""" + try: + with urllib.request.urlopen(f"{SERVICE}/api/state", timeout=timeout) as r: + return json.load(r) + except (urllib.error.URLError, OSError, ValueError, json.JSONDecodeError): + return None + + +def service_grab(ids: list[int], state: list[dict]) -> dict[int, dict]: + """Pull already-captured frames from the vmwatch service. No guest load.""" + by_id = {g["id"]: g for g in state} + out: dict[int, dict] = {} + for vid in ids: + g = by_id.get(vid) + if g is None: + out[vid] = {"png": None, "status": "absent", "log": "not known to vmwatch"} + continue + png = None + if g.get("ok"): + try: + with urllib.request.urlopen( + f"{SERVICE}/shot/{vid}.png?t={g.get('ts', '')}", timeout=10 + ) as r: + png = r.read() + except (urllib.error.URLError, OSError): + png = None + out[vid] = { + "png": png, + "status": g.get("status", "?"), + "log": g.get("error", "") if not png else "", + } + return out + + +def discover_fleet() -> list[tuple[int, str, str]]: + """Return [(vmid, name, status)] for every VM whose name matches sots-*.""" + r = ssh("qm list", timeout=30) + if r.returncode != 0: + sys.exit(f"vmshot: `qm list` on {HOST} failed: {r.stderr.decode().strip()}") + fleet = [] + for line in r.stdout.decode().splitlines()[1:]: + parts = line.split() + if len(parts) < 3 or not parts[0].isdigit(): + continue + vmid, name, status = int(parts[0]), parts[1], parts[2] + if FLEET_PATTERN.search(name): + fleet.append((vmid, name, status)) + return sorted(fleet) + + +# Remote script: dump every requested guest in parallel, tar the results back, +# always clean up. Per-guest stderr is kept so a failure explains itself. +REMOTE = r""" +set -u +D=$(mktemp -d /tmp/vmshot.XXXXXX) +trap 'rm -rf "$D"' EXIT INT TERM +for id in %(ids)s; do + ( + st=$(qm status "$id" 2>&1 | awk '{print $2}') + echo "$st" > "$D/$id.status" + timeout %(t)s qm monitor "$id" <<< "screendump $D/$id.png -f png" \ + > "$D/$id.log" 2>&1 + # qm monitor exits 0 even when the monitor command errors; the monitor + # echoes the failure into the log, so treat "no file" as the real test. + [ -s "$D/$id.png" ] || echo "no framebuffer written" >> "$D/$id.log" + ) & +done +wait +tar -C "$D" -cf - . | base64 -w0 +""" + + +def grab(ids: list[int], per_vm_timeout: int = 20) -> dict[int, dict]: + """Screendump each id on the host; return {id: {'png': bytes|None, 'status', 'log'}}.""" + remote = REMOTE % {"ids": " ".join(str(i) for i in ids), "t": per_vm_timeout} + r = ssh(f"bash -s <<'VMSHOT_EOF'\n{remote}\nVMSHOT_EOF", timeout=per_vm_timeout + 60) + if r.returncode != 0 and not r.stdout.strip(): + sys.exit(f"vmshot: remote grab failed: {r.stderr.decode().strip()[:500]}") + + out: dict[int, dict] = {i: {"png": None, "status": "?", "log": ""} for i in ids} + try: + blob = base64.b64decode(r.stdout.strip()) + with tarfile.open(fileobj=io.BytesIO(blob), mode="r:") as tf: + for m in tf.getmembers(): + name = Path(m.name).name + if not m.isfile(): + continue + stem, _, ext = name.rpartition(".") + if not stem.isdigit(): + continue + vid = int(stem) + if vid not in out: + continue + data = tf.extractfile(m).read() + if ext == "png": + out[vid]["png"] = data + elif ext == "status": + out[vid]["status"] = data.decode(errors="replace").strip() or "?" + elif ext == "log": + out[vid]["log"] = data.decode(errors="replace").strip() + except Exception as e: # noqa: BLE001 - a mangled tar must not lose the whole sheet + for v in out.values(): + v["log"] = v["log"] or f"could not unpack remote payload: {e}" + return out + + +def _font(size: int, bold: bool = False) -> ImageFont.FreeTypeFont: + try: + return ImageFont.truetype(FONT_BOLD if bold else FONT_PATH, size) + except OSError: + return ImageFont.load_default() + + +def _clean_monitor_log(log: str) -> str: + """Strip the monitor banner/prompt noise, keep the actual complaint.""" + keep = [] + for ln in log.splitlines(): + ln = ln.replace("\x1b", "").strip() + ln = re.sub(r"^(QEMU \d[\w.]* monitor.*|qm> ?)", "", ln).strip() + if not ln or ln.startswith("Entering QEMU Monitor") or ln.startswith("Type 'help'"): + continue + keep.append(ln) + return "; ".join(keep)[:180] + + +def make_tile(vmid: int, name: str, info: dict, width: int, stamp: str) -> Image.Image: + png = info.get("png") + status = info.get("status", "?") + err = _clean_monitor_log(info.get("log", "")) + + if png: + shot = Image.open(io.BytesIO(png)).convert("RGB") + native = f"{shot.width}x{shot.height}" + h = max(1, round(shot.height * width / shot.width)) + shot = shot.resize((width, h), Image.LANCZOS) + detail = f"{status} {native} {stamp}" + bad = False + else: + h = round(width * 3 / 4) + shot = Image.new("RGB", (width, h), PLACEHOLDER_BG) + d = ImageDraw.Draw(shot) + msg = err or f"no screendump ({status})" + d.text( + (width // 2, h // 2 - 12), + "NO SIGNAL", + font=_font(max(18, width // 22), bold=True), + fill=(210, 120, 120), + anchor="mm", + ) + f = _font(max(10, width // 60)) + # crude wrap + line, lines = "", [] + for word in msg.split(): + if len(line) + len(word) + 1 > 52: + lines.append(line) + line = word + else: + line = f"{line} {word}".strip() + lines.append(line) + for i, ln in enumerate(lines[:4]): + d.text( + (width // 2, h // 2 + 18 + i * 15), + ln, + font=f, + fill=(190, 150, 150), + anchor="mm", + ) + detail = f"{status} {stamp}" + bad = True + + tile = Image.new("RGB", (width, h + LABEL_H), LABEL_BG_BAD if bad else LABEL_BG) + tile.paste(shot, (0, LABEL_H)) + d = ImageDraw.Draw(tile) + f_id, f_name, f_detail = _font(16, bold=True), _font(14), _font(11) + y = LABEL_H // 2 + + d.text((8, y), str(vmid), font=f_id, fill=FG, anchor="lm") + name_x = 8 + round(d.textlength(str(vmid), font=f_id)) + 10 + + # A narrow tile must not let the two labels overlap: drop the timestamp + # first, then ellipsise the guest name to whatever room is left. + for candidate in (detail, detail.rsplit(" ", 1)[0], status): + detail_w = d.textlength(candidate, font=f_detail) + if name_x + d.textlength(name, font=f_name) + 12 + detail_w + 8 <= width: + detail = candidate + break + else: + detail = status + detail_w = d.textlength(detail, font=f_detail) + + room = width - 8 - detail_w - 12 - name_x + shown = name + while shown and d.textlength(shown + "…", font=f_name) > room: + shown = shown[:-1] + if shown != name: + shown = (shown + "…") if shown else "" + if room > 0 and shown: + d.text((name_x, y), shown, font=f_name, fill=FG, anchor="lm") + d.text((width - 8, y), detail, font=f_detail, fill=FG_DIM, anchor="rm") + d.rectangle([0, 0, width - 1, h + LABEL_H - 1], outline=BORDER) + return tile + + +def contact_sheet(fleet, shots, tile_w: int, cols: int, stamp: str) -> Image.Image: + tiles = [make_tile(vid, name, shots.get(vid, {}), tile_w, stamp) for vid, name, _ in fleet] + rows = (len(tiles) + cols - 1) // cols + row_h = [ + max((t.height for t in tiles[r * cols : (r + 1) * cols]), default=0) for r in range(rows) + ] + header = 40 + W = PAD + cols * (tile_w + PAD) + H = header + PAD + sum(h + PAD for h in row_h) + sheet = Image.new("RGB", (W, H), BG) + d = ImageDraw.Draw(sheet) + live = sum(1 for v in shots.values() if v.get("png")) + d.text((PAD, header // 2), "SOTS lab guests", font=_font(18, bold=True), fill=FG, anchor="lm") + d.text( + (W - PAD, header // 2), + f"{live}/{len(fleet)} framebuffers captured {stamp} host={HOST}", + font=_font(12), + fill=FG_DIM, + anchor="rm", + ) + y = header + PAD + for r in range(rows): + x = PAD + for t in tiles[r * cols : (r + 1) * cols]: + sheet.paste(t, (x, y)) + x += tile_w + PAD + y += row_h[r] + PAD + return sheet + + +def main() -> int: + ap = argparse.ArgumentParser(description="Contact sheet of the SOTS lab VM screens.") + ap.add_argument("ids", nargs="*", type=int, help="VM ids (default: every sots-* guest)") + ap.add_argument("--one", type=int, metavar="ID", help="save one guest full-size, no sheet") + ap.add_argument("--cols", type=int, default=3) + ap.add_argument("--width", type=int, default=760, help="tile width in px") + ap.add_argument("--out", type=Path, help="output png path") + ap.add_argument("--timeout", type=int, default=20, help="per-guest screendump timeout (s)") + ap.add_argument("--keep-raw", action="store_true", help="also save each guest's raw png") + ap.add_argument("--json", action="store_true", help="print a machine-readable result line") + ap.add_argument("--open", action="store_true", help="xdg-open the result") + ap.add_argument( + "--ssh", + action="store_true", + help="always capture over SSH, even if the vmwatch service is reachable", + ) + args = ap.parse_args() + + OUT_DIR.mkdir(parents=True, exist_ok=True) + t0 = time.time() + + state = None if args.ssh else service_state() + if state is not None: + names = {g["id"]: g["name"] for g in state} + all_ids = [g["id"] for g in state] + source = SERVICE + else: + discovered = discover_fleet() + names = {v: n for v, n, _ in discovered} + all_ids = [v for v, _, _ in discovered] + source = f"ssh {HOST}" + + if args.one is not None: + wanted = [args.one] + elif args.ids: + wanted = args.ids + else: + wanted = all_ids + if not wanted: + sys.exit(f"vmshot: no sots-* guests found via {source}") + fleet = [(v, names.get(v, f"vm{v}"), "") for v in wanted] + + shots = service_grab(wanted, state) if state is not None else grab(wanted, args.timeout) + now = datetime.now() + stamp = now.strftime("%Y-%m-%d %H:%M:%S") + tag = now.strftime("%Y%m%d-%H%M%S") + + if args.keep_raw or args.one is not None: + for vid, info in shots.items(): + if info.get("png"): + p = OUT_DIR / f"vm{vid}-{tag}.png" + p.write_bytes(info["png"]) + + if args.one is not None: + info = shots.get(args.one, {}) + if not info.get("png"): + print( + f"vmshot: no framebuffer from {args.one}: " + f"{_clean_monitor_log(info.get('log', '')) or info.get('status', '?')}", + file=sys.stderr, + ) + return 1 + out = args.out or (OUT_DIR / f"vm{args.one}-{tag}.png") + out.write_bytes(info["png"]) + else: + sheet = contact_sheet(fleet, shots, args.width, args.cols, stamp) + out = args.out or (OUT_DIR / f"sheet-{tag}.png") + sheet.save(out) + if args.out is None: # only the default location keeps a `latest` alias + (OUT_DIR / "latest.png").write_bytes(out.read_bytes()) + + live = sum(1 for v in shots.values() if v.get("png")) + if args.json: + print( + json.dumps( + { + "out": str(out), + "captured": live, + "requested": len(wanted), + "source": source, + "seconds": round(time.time() - t0, 1), + "guests": { + str(v): { + "name": names.get(v, ""), + "status": i.get("status"), + "ok": bool(i.get("png")), + "error": _clean_monitor_log(i.get("log", "")) or None, + } + for v, i in sorted(shots.items()) + }, + }, + indent=2, + ) + ) + else: + for v, i in sorted(shots.items()): + mark = "ok " if i.get("png") else "FAIL" + note = "" if i.get("png") else " " + (_clean_monitor_log(i.get("log", "")) or "?") + print(f" {mark} {v:<5} {names.get(v, ''):<20} {i.get('status', '?')}{note}") + print( + f"vmshot: {live}/{len(wanted)} captured in {time.time() - t0:.1f}s " + f"via {source} -> {out}" + ) + + if args.open: + subprocess.run(["xdg-open", str(out)], check=False) + return 0 if live else 2 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/vmwatch-install.sh b/tools/vmwatch-install.sh new file mode 100755 index 0000000..5d5bed0 --- /dev/null +++ b/tools/vmwatch-install.sh @@ -0,0 +1,60 @@ +#!/usr/bin/env bash +# Install / update the vmwatch fishtank service on the Proxmox host. +# +# What this puts on spicy (and nothing else -- no packages, no pip): +# /opt/vmwatch/vmwatch.py the service (Python 3 stdlib only) +# /etc/systemd/system/vmwatch.service +# +# Usage: tools/vmwatch-install.sh [host] (default host: spicy) +# tools/vmwatch-install.sh spicy --uninstall +set -euo pipefail + +HOST="${1:-spicy}" +SRC="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/vmwatch.py" +PORT="${VMWATCH_PORT:-8140}" +INTERVAL="${VMWATCH_INTERVAL:-5}" + +if [[ "${2:-}" == "--uninstall" ]]; then + ssh "$HOST" 'systemctl disable --now vmwatch.service 2>/dev/null || true + rm -f /etc/systemd/system/vmwatch.service + rm -rf /opt/vmwatch + systemctl daemon-reload + echo "vmwatch removed from $(hostname)"' + exit 0 +fi + +echo "installing vmwatch on $HOST (port $PORT, interval ${INTERVAL}s)" +ssh "$HOST" "mkdir -p /opt/vmwatch" +scp -q "$SRC" "$HOST:/opt/vmwatch/vmwatch.py" +ssh "$HOST" "chmod 0755 /opt/vmwatch/vmwatch.py" + +ssh "$HOST" "cat > /etc/systemd/system/vmwatch.service" </dev/null \ + && systemctl restart vmwatch.service && sleep 2 && systemctl is-active vmwatch.service" +IP=$(ssh "$HOST" "hostname -I | tr ' ' '\n' | grep -E '^192\.168\.' | head -1") +echo +echo "http://${IP}:${PORT}/" diff --git a/tools/vmwatch.py b/tools/vmwatch.py new file mode 100755 index 0000000..4811d12 --- /dev/null +++ b/tools/vmwatch.py @@ -0,0 +1,637 @@ +#!/usr/bin/env python3 +"""vmwatch -- a fishtank for the SOTS lab Windows guests. + +A small always-on HTTP service that serves an auto-refreshing page of live +guest screens. Leave the tab open and watch the lab. + + http://192.168.3.201:8140/ + +Where it runs +------------- +On the Proxmox host **spicy** itself, because it needs `qm`. Installed as +``/opt/vmwatch/vmwatch.py`` with a systemd unit ``vmwatch.service``. The +canonical copy lives here in ``~/sots-re/tools/`` -- edit here, then reinstall +with ``tools/vmwatch-install.sh``. + +Dependencies: **Python 3 standard library only.** Nothing is installed on +spicy beyond this file and its unit -- no pip, no ImageMagick, no netpbm (none +of which are present there). + +Mechanism +--------- +A QMP ``screendump`` sent to each guest's ``/var/run/qemu-server/.qmp`` +unix socket -- the same socket, and the same command, that +``qm monitor <<< "screendump -f png"`` uses, minus the Perl. That +detail is not cosmetic: forking ``qm`` once per guest per poll interval cost +about 90%% of a core on the host, which is not acceptable on a box that is also +running the guests. ``qm monitor`` remains the fallback when the socket is +absent. Either way this reads the guest framebuffer straight out of QEMU: + +* no guest agent, no guest network, nothing running inside the guest; +* it does **not** perturb the guest -- relevant to method-rule 19, an + instrument that perturbs the thing it measures has already burned this + campaign once. Reading VM 140's screen is therefore not an experiment and + does not take VM 140's exclusivity lock; +* the campaign board already records that ``qm monitor`` screendump is more + reliable than the in-guest click helper's ``shot``. + +spicy runs pve-manager 9.2 / QEMU 11, whose ``screendump`` accepts ``-f png`` +natively. On an older QEMU without ``-f``, this falls back to a PPM dump and a +hand-rolled PPM->PNG encoder (zlib + a stored-filter IDAT), so no external +image tooling is needed either way. Temp files are read into memory and +unlinked immediately; nothing accumulates in /tmp. + +Politeness +---------- +A single background poller grabs every guest on a fixed interval and serves +every browser from that one in-memory copy, so N open tabs still cost one +screendump per guest per interval. The poller goes idle when no client has +asked for anything in IDLE_AFTER seconds, and wakes on the next request. + +Routes +------ + GET / the wall + GET /one/ one guest, full size + GET /api/state JSON: per-guest id, name, status, ok, error, age + GET /shot/.png the latest cached framebuffer for that guest + GET /healthz plaintext ok + +Read-only: this service never starts, stops, resets or reconfigures anything. +""" + +from __future__ import annotations + +import argparse +import html +import json +import os +import re +import socket +import struct +import subprocess +import sys +import tempfile +import threading +import time +import zlib +from concurrent.futures import ThreadPoolExecutor +from http import HTTPStatus +from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer + +FLEET_PATTERN = re.compile(r"sots-re", re.I) +DEFAULT_PORT = 8140 +DEFAULT_INTERVAL = 5.0 # seconds between framebuffer grabs +FLEET_TTL = 20.0 # seconds between `qm list` refreshes +IDLE_AFTER = 120.0 # stop polling if nobody has looked for this long +DUMP_TIMEOUT = 15 # per-guest screendump timeout, seconds + + +# -------------------------------------------------------------------------- +# host-side capture +# -------------------------------------------------------------------------- + + +CONF_DIR = "/etc/pve/qemu-server" +QMP_DIR = "/var/run/qemu-server" + + +def _run(cmd: list[str], timeout: int, stdin: str | None = None) -> subprocess.CompletedProcess: + return subprocess.run( + cmd, input=stdin, capture_output=True, text=True, timeout=timeout, check=False + ) + + +class QmpError(Exception): + pass + + +class Qmp: + """Minimal QMP client over the per-VM unix socket PVE already exposes. + + This is the same socket `qm monitor` uses, opened the same way (connect, + negotiate, one command, close). Going straight to it instead of shelling + out to `qm` matters: `qm` is a Perl program and forking one per guest per + poll interval cost ~90%% of a core on the host. Falls back to `qm monitor` + if the socket is missing. + """ + + def __init__(self, vmid: int, timeout: float = 10.0): + self.path = os.path.join(QMP_DIR, f"{vmid}.qmp") + self.timeout = timeout + self.sock: socket.socket | None = None + self.buf = b"" + + def __enter__(self) -> Qmp: + s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + s.settimeout(self.timeout) + s.connect(self.path) + self.sock = s + self._read_json() # greeting + self.execute("qmp_capabilities") + return self + + def __exit__(self, *exc) -> None: + if self.sock: + try: + self.sock.close() + except OSError: + pass + + def _read_json(self) -> dict: + while True: + nl = self.buf.find(b"\n") + if nl >= 0: + line, self.buf = self.buf[:nl], self.buf[nl + 1 :] + if line.strip(): + msg = json.loads(line) + if "event" in msg: # async event: not our reply + continue + return msg + continue + chunk = self.sock.recv(65536) # type: ignore[union-attr] + if not chunk: + raise QmpError("monitor closed the connection") + self.buf += chunk + + def execute(self, cmd: str, **args) -> dict: + payload = {"execute": cmd} + if args: + payload["arguments"] = args + self.sock.sendall(json.dumps(payload).encode() + b"\n") # type: ignore[union-attr] + reply = self._read_json() + if "error" in reply: + raise QmpError(reply["error"].get("desc", json.dumps(reply["error"]))[:200]) + return reply.get("return", {}) + + +def _conf_name(vmid: int) -> str | None: + """Guest name from its PVE config, ignoring the [snapshot] sections.""" + try: + with open(os.path.join(CONF_DIR, f"{vmid}.conf"), encoding="utf-8") as fh: + for line in fh: + if line.startswith("["): # snapshot section begins + break + if line.startswith("name:"): + return line.split(":", 1)[1].strip() + except OSError: + return None + return None + + +def list_fleet() -> list[dict]: + """[{id, name, status}] for every VM whose name matches sots-re. + + Read from /etc/pve/qemu-server + the QMP sockets rather than `qm list`, + which is another Perl fork. Falls back to `qm list` if that is not + readable (e.g. running somewhere that is not a PVE node). + """ + fleet = [] + try: + entries = os.listdir(CONF_DIR) + except OSError: + entries = [] + for entry in entries: + stem, _, ext = entry.rpartition(".") + if ext != "conf" or not stem.isdigit(): + continue + vmid = int(stem) + name = _conf_name(vmid) + if not name or not FLEET_PATTERN.search(name): + continue + status = "stopped" + if os.path.exists(os.path.join(QMP_DIR, f"{vmid}.qmp")): + try: + with Qmp(vmid, timeout=5) as q: + status = q.execute("query-status").get("status", "running") + except (OSError, QmpError, ValueError): + status = "unreachable" + fleet.append({"id": vmid, "name": name, "status": status}) + if fleet: + return sorted(fleet, key=lambda g: g["id"]) + + try: + r = _run(["qm", "list"], timeout=20) + except (subprocess.TimeoutExpired, OSError): + return [] + for line in r.stdout.splitlines()[1:]: + parts = line.split() + if len(parts) < 3 or not parts[0].isdigit(): + continue + if FLEET_PATTERN.search(parts[1]): + fleet.append({"id": int(parts[0]), "name": parts[1], "status": parts[2]}) + return sorted(fleet, key=lambda g: g["id"]) + + +def ppm_to_png(data: bytes) -> bytes: + """Minimal binary-P6 PPM -> PNG. Only used if QEMU lacks screendump -f png.""" + if not data.startswith(b"P6"): + raise ValueError("not a binary P6 PPM") + # header: P6, then width height maxval, whitespace separated, # comments allowed + fields, pos = [], 2 + while len(fields) < 3: + while pos < len(data) and data[pos : pos + 1].isspace(): + pos += 1 + if data[pos : pos + 1] == b"#": + while pos < len(data) and data[pos] != 0x0A: + pos += 1 + continue + start = pos + while pos < len(data) and not data[pos : pos + 1].isspace(): + pos += 1 + fields.append(int(data[start:pos])) + pos += 1 # single whitespace byte after maxval + w, h, maxval = fields + if maxval != 255: + raise ValueError(f"unsupported PPM maxval {maxval}") + px = data[pos : pos + w * h * 3] + stride = w * 3 + raw = bytearray() + for y in range(h): + raw.append(0) # filter type 0 (None) + raw += px[y * stride : (y + 1) * stride] + + def chunk(tag: bytes, payload: bytes) -> bytes: + return ( + struct.pack(">I", len(payload)) + + tag + + payload + + struct.pack(">I", zlib.crc32(tag + payload) & 0xFFFFFFFF) + ) + + return ( + b"\x89PNG\r\n\x1a\n" + + chunk(b"IHDR", struct.pack(">IIBBBBB", w, h, 8, 2, 0, 0, 0)) + + chunk(b"IDAT", zlib.compress(bytes(raw), 6)) + + chunk(b"IEND", b"") + ) + + +def screendump(vmid: int, png_native: bool = True) -> tuple[bytes | None, str]: + """Grab one guest framebuffer. Returns (png_bytes, error_message); never raises.""" + suffix = ".png" if png_native else ".ppm" + fd, path = tempfile.mkstemp(prefix=f"vmwatch-{vmid}-", suffix=suffix, dir="/tmp") + os.close(fd) + os.unlink(path) # QEMU creates it itself; we only wanted a unique name + err = "" + try: + if os.path.exists(os.path.join(QMP_DIR, f"{vmid}.qmp")): + args = {"filename": path} + if png_native: + args["format"] = "png" + with Qmp(vmid, timeout=DUMP_TIMEOUT) as q: + q.execute("screendump", **args) + else: + # not running, or not a PVE node: fall back to the CLI monitor + cmd = f"screendump {path}" + (" -f png" if png_native else "") + r = _run(["qm", "monitor", str(vmid)], timeout=DUMP_TIMEOUT, stdin=cmd + "\n") + # `qm monitor` exits 0 even when the monitor command itself errors; + # it echoes the failure to stdout, so "no file" is the real test. + err = _clean_monitor_text(r.stdout + r.stderr) + except FileNotFoundError: + _unlink(path) + return None, "guest is not running (no monitor socket)" + except (socket.timeout, subprocess.TimeoutExpired): + _unlink(path) + return None, "screendump timed out" + except (QmpError, OSError, ValueError) as e: + _unlink(path) + return None, str(e) or f"{type(e).__name__}" + + try: + with open(path, "rb") as fh: + blob = fh.read() + except OSError: + blob = b"" + finally: + _unlink(path) + + if not blob: + return None, err or "no framebuffer written" + if png_native and blob.startswith(b"\x89PNG"): + return blob, "" + try: + return ppm_to_png(blob), "" + except Exception as e: # noqa: BLE001 + return None, f"could not decode framebuffer: {e}" + + +def _unlink(path: str) -> None: + try: + os.unlink(path) + except OSError: + pass + + +def _clean_monitor_text(text: str) -> str: + keep = [] + for ln in text.replace("\x1b", "").splitlines(): + ln = re.sub(r"^(QEMU \d[\w.]* monitor.*|qm> ?)", "", ln.strip()).strip() + if not ln or ln.startswith(("Entering QEMU Monitor", "Type 'help'", "screendump ")): + continue + keep.append(ln) + return "; ".join(keep)[:200] + + +# -------------------------------------------------------------------------- +# poller +# -------------------------------------------------------------------------- + + +class Tank: + def __init__(self, interval: float): + self.interval = interval + self.lock = threading.Lock() + self.shots: dict[int, dict] = {} # id -> {png, error, ts} + self.fleet: list[dict] = [] + self.fleet_ts = 0.0 + self.last_client = time.time() + self.png_native = True + self.cycles = 0 + self.started = time.time() + self.wake = threading.Event() + + def touch(self) -> None: + idle = time.time() - self.last_client > IDLE_AFTER + self.last_client = time.time() + if idle: + self.wake.set() # first look after a nap: refresh right away + + def get_fleet(self) -> list[dict]: + with self.lock: + fresh = time.time() - self.fleet_ts < FLEET_TTL and self.fleet + if fresh: + return list(self.fleet) + fleet = list_fleet() + with self.lock: + if fleet or not self.fleet: + self.fleet, self.fleet_ts = fleet, time.time() + return list(self.fleet) + + def cycle(self) -> None: + fleet = self.get_fleet() + if not fleet: + return + with ThreadPoolExecutor(max_workers=max(1, len(fleet))) as pool: + results = list(pool.map(lambda g: (g["id"], screendump(g["id"], self.png_native)), fleet)) + now = time.time() + with self.lock: + for vmid, (png, err) in results: + prev = self.shots.get(vmid, {}) + self.shots[vmid] = { + "png": png if png else prev.get("png"), + "fresh": bool(png), + "error": err, + "ts": now if png else prev.get("ts", 0.0), + } + live = {g["id"] for g in fleet} + for gone in set(self.shots) - live: + del self.shots[gone] + self.cycles += 1 + # one-time downgrade if this QEMU has no `-f png` + if self.png_native and all(not p for _, (p, _) in results): + errs = " ".join(e for _, (_, e) in results) + if "format" in errs.lower() or "invalid" in errs.lower() or "-f" in errs: + self.png_native = False + print("vmwatch: QEMU lacks screendump -f png; falling back to PPM", flush=True) + + def loop(self) -> None: + while True: + if time.time() - self.last_client <= IDLE_AFTER: + try: + self.cycle() + except Exception as e: # noqa: BLE001 - the poller must never die + print(f"vmwatch: cycle error: {e}", file=sys.stderr, flush=True) + self.wake.wait(self.interval) + else: + self.wake.wait(2.0) + self.wake.clear() + + def state(self) -> list[dict]: + now = time.time() + out = [] + with self.lock: + shots = dict(self.shots) + for g in self.get_fleet(): + s = shots.get(g["id"], {}) + out.append( + { + **g, + "ok": bool(s.get("png")), + "fresh": bool(s.get("fresh")), + "error": s.get("error") or "", + "age": round(now - s["ts"], 1) if s.get("ts") else None, + "ts": time.strftime("%H:%M:%S", time.localtime(s["ts"])) if s.get("ts") else "", + } + ) + return out + + def png(self, vmid: int) -> bytes | None: + with self.lock: + return (self.shots.get(vmid) or {}).get("png") + + +# -------------------------------------------------------------------------- +# page +# -------------------------------------------------------------------------- + +PAGE = """ + + +%(title)s + + +
+ +

%(title)s

+ %(nav)s + + connecting... +
+
+ +""" + + +class Handler(BaseHTTPRequestHandler): + server_version = "vmwatch" + tank: Tank = None # type: ignore[assignment] + + def log_message(self, fmt, *a): # quieter than the default one-line-per-image + pass + + def _send(self, code, ctype, body: bytes, cache: str = "no-store"): + self.send_response(code) + self.send_header("Content-Type", ctype) + self.send_header("Content-Length", str(len(body))) + self.send_header("Cache-Control", cache) + self.end_headers() + try: + self.wfile.write(body) + except (BrokenPipeError, ConnectionResetError): + pass + + def _page(self, only: int | None): + title = f"SOTS guest {only}" if only else "SOTS lab guests" + nav = '← all guests' if only else "" + body = PAGE % { + "title": html.escape(title), + "nav": nav, + "cls": "one" if only else "", + "only": only if only else "null", + "interval": self.tank.interval, + "minw": 900 if only else 460, + } + self._send(HTTPStatus.OK, "text/html; charset=utf-8", body.encode()) + + def do_GET(self): # noqa: N802 + path = self.path.split("?", 1)[0] + self.tank.touch() + + if path == "/": + return self._page(None) + if m := re.fullmatch(r"/one/(\d+)/?", path): + return self._page(int(m.group(1))) + if path == "/api/state": + return self._send( + HTTPStatus.OK, "application/json", json.dumps(self.tank.state()).encode() + ) + if m := re.fullmatch(r"/shot/(\d+)\.png", path): + png = self.tank.png(int(m.group(1))) + if png: + # immutable: the URL carries the capture timestamp + return self._send(HTTPStatus.OK, "image/png", png, "max-age=30") + return self._send(HTTPStatus.NOT_FOUND, "text/plain", b"no framebuffer\n") + if path == "/healthz": + t = self.tank + up = int(time.time() - t.started) + return self._send( + HTTPStatus.OK, + "text/plain", + f"ok guests={len(t.get_fleet())} cycles={t.cycles} uptime={up}s\n".encode(), + ) + return self._send(HTTPStatus.NOT_FOUND, "text/plain", b"not found\n") + + +def main() -> int: + ap = argparse.ArgumentParser(description=__doc__.splitlines()[0]) + ap.add_argument("--port", type=int, default=DEFAULT_PORT) + ap.add_argument("--bind", default="0.0.0.0") + ap.add_argument("--interval", type=float, default=DEFAULT_INTERVAL) + args = ap.parse_args() + + tank = Tank(args.interval) + Handler.tank = tank + threading.Thread(target=tank.loop, daemon=True, name="poller").start() + srv = ThreadingHTTPServer((args.bind, args.port), Handler) + srv.daemon_threads = True + print( + f"vmwatch: http://{args.bind}:{args.port}/ interval={args.interval}s " + f"guests={[g['id'] for g in tank.get_fleet()]}", + flush=True, + ) + try: + srv.serve_forever() + except KeyboardInterrupt: + pass + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())