test: verify independent guests and recovery on private displays

This commit is contained in:
Codex
2026-10-13 04:44:46 +03:00
parent 30ac68ed60
commit 664c995f84
11 changed files with 608 additions and 47 deletions
+157 -45
View File
@@ -1,14 +1,16 @@
#!/usr/bin/env python3
"""Disposable headless GUI checks. Never connects to an inherited display."""
"""Two disposable KVM guests on a private headless display; never host input."""
import argparse
import json
import os
from pathlib import Path
import re
import shutil
import subprocess
import sys
import tempfile
import time
from xml.etree import ElementTree as ET
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from hydra.profiles import Profile
@@ -25,24 +27,49 @@ def wait_for(predicate, description, timeout=8):
raise AssertionError(description)
def counter(manager, name):
with manager._connect(manager.paths(name)) as qmp:
reply = qmp.command('human-monitor-command', {'command-line': 'xp /3wx 0x500'})
values = [int(word, 16) for word in re.findall(r'0x([0-9a-fA-F]+)', reply)]
assert len(values) == 3 and values[2] == 0x48594452, 'scratch guest did not boot'
return values[0]
def main():
cli = argparse.ArgumentParser(description=__doc__)
cli.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer'))
cli.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc'))
cli.add_argument('--keyboard', default='wtype')
cli.add_argument('--keyboard', help='compiled dev/input-probe.c; built automatically when omitted')
args = cli.parse_args()
for name in ('viewer', 'labwc', 'keyboard'):
for name in ('viewer', 'labwc'):
value = shutil.which(getattr(args, name))
if not value:
cli.error(f'missing {name}: {getattr(args, name)}')
setattr(args, name, value)
root = Path(tempfile.mkdtemp(prefix='hydra-smoke-'))
source = Path(__file__).resolve().parent
if not args.keyboard:
args.keyboard = subprocess.check_output([str(source / 'build-input-probe.sh')], text=True).strip()
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
cli.error('KVM access required for this integration check')
root = Path(tempfile.mkdtemp(prefix='hydra-smoke-', dir='/tmp'))
print(f'Evidence: {root}', flush=True)
xdg = root / 'xdg'
xdg.mkdir(mode=0o700)
config = root / 'labwc'
config.mkdir()
(config / 'rc.xml').write_text(build_labwc_config(['probe'], ['/bin/true']))
recovery = root / 'recovery'
unreserved = root / 'unreserved'
names = ['alpha', 'beta']
rc = ET.fromstring(build_labwc_config(names, [sys.executable, '-c',
'from pathlib import Path; Path(' + repr(str(recovery)) + ').touch()'], initial='alpha'))
# Test-only controls prove native labwc inhibition is active; production
# config has no ToggleKeybinds or ordinary host shortcut.
keyboard = rc.find('keyboard')
binding = ET.SubElement(keyboard, 'keybind', key='C-A-S-i')
ET.SubElement(binding, 'action', name='ToggleKeybinds')
binding = ET.SubElement(keyboard, 'keybind', key='F12')
ET.SubElement(binding, 'action', name='Execute', command=shutil.which('touch') + ' ' + str(unreserved))
ET.ElementTree(rc).write(config / 'rc.xml')
(config / 'autostart').write_text('#!/bin/sh\n')
(config / 'environment').touch()
env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), WLR_BACKENDS='headless',
@@ -51,55 +78,140 @@ def main():
for key in ('DISPLAY', 'WAYLAND_DISPLAY'):
env.pop(key, None)
manager = Runtime(root / 'config', root / 'state', root / 'run')
manager.add(Profile('probe', memory=128, cpus=1, disk_size='8M', graphics='software'))
processes = []
boot_object, boot = root / 'boot.o', root / 'boot.bin'
subprocess.run(['as', '--32', str(source / 'guest-probe.S'), '-o', str(boot_object)], check=True)
subprocess.run(['objcopy', '-j', '.text', '-O', 'binary', str(boot_object), str(boot)], check=True)
raw = root / 'boot.raw'
raw.write_bytes(boot.read_bytes() + bytes(8 * 1024 * 1024 - 512))
media = root / 'installation.iso'
media.touch()
for name in names:
manager.add(Profile(name, media if name == 'alpha' else None, memory=128, cpus=1, disk_size='8M', graphics='software',
width=1920, height=1080, refresh_millihz=144000))
paths = manager.paths(name)
manager.prepare(paths)
subprocess.run([os.environ.get('HYDRA_QEMU_IMG', 'qemu-img'), 'convert', '-f', 'raw',
'-O', 'qcow2', str(raw), str(paths.disk)], check=True)
processes, streams = [], []
result = {'evidence': str(root), 'physical_checks_pending': True}
try:
with (root / 'labwc.log').open('w') as cl, (root / 'viewer.log').open('w') as vl:
compositor = subprocess.Popen([args.labwc, '-d', '-C', str(config)], env=env, stdout=cl, stderr=cl)
processes.append(compositor)
wait_for(lambda: list(xdg.glob('wayland-*')), 'private display did not start')
env['WAYLAND_DISPLAY'] = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
keyboard = subprocess.Popen([args.keyboard, '-k', 'Escape', '-s', '30000'], env=env)
processes.append(keyboard)
time.sleep(.2)
manager.start('probe')
cl = (root / 'labwc.log').open('w'); streams.append(cl)
compositor = subprocess.Popen([args.labwc, '-d', '-C', str(config)], env=env, stdout=cl, stderr=cl)
processes.append(compositor)
wait_for(lambda: any(not p.name.endswith('.lock') for p in xdg.glob('wayland-*')), 'private display did not start')
env['WAYLAND_DISPLAY'] = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
injector = subprocess.Popen([args.keyboard], env=env, stdin=subprocess.PIPE, text=True)
processes.append(injector)
def keys(commands):
injector.stdin.write(commands + '\nwait 100\n'); injector.stdin.flush()
time.sleep(.15)
assert injector.poll() is None, 'private input helper exited'
def chord(code):
keys(f'down 29\ndown 56\ndown 42\ndown {code}\nup {code}\nup 42\nup 56\nup 29')
def attach(name, number):
vl = (root / f'{name}-{number}.log').open('w'); streams.append(vl)
previous = dict(os.environ)
try:
os.environ.update(env)
viewer_env = _viewer_environment(root / 'viewer-state')
viewer_env = _viewer_environment(root / f'{name}-{number}-settings')
finally:
os.environ.clear()
os.environ.update(previous)
os.environ.clear(); os.environ.update(previous)
viewer_env['WAYLAND_DEBUG'] = '1'
viewer = subprocess.Popen(build_viewer_argv(manager.paths('probe'), args.viewer),
env=viewer_env, stdout=vl, stderr=vl)
processes.append(viewer)
log = root / 'viewer.log'
wait_for(lambda: 'set_title("HydraOS: probe")' in log.read_text(), 'viewer did not connect')
time.sleep(.5)
title = 'set_title("Select USB devices for redirection")'
assert title not in log.read_text(), 'USB selector opened automatically'
subprocess.run([args.keyboard, '-M', 'ctrl', '-M', 'alt', '-M', 'shift', '-k', 'u',
'-m', 'shift', '-m', 'alt', '-m', 'ctrl'], env=env, check=True)
wait_for(lambda: title in log.read_text(), 'manual USB selector did not open', timeout=3)
viewer.terminate()
viewer.wait(timeout=5)
assert manager.status('probe')['status'] == 'running'
compositor.terminate()
compositor.wait(timeout=5)
assert manager.status('probe')['status'] == 'running'
result = {'manual_usb_dialog': True, 'automatic_usb_dialog': False,
'qemu_survives_viewer_and_compositor': True,
'viewer_version': subprocess.check_output([args.viewer, '--version'], text=True).strip()}
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))
proc = subprocess.Popen(build_viewer_argv(manager.paths(name), args.viewer),
env=viewer_env, stdout=vl, stderr=vl)
processes.append(proc)
log = root / f'{name}-{number}.log'
wait_for(lambda: f'set_title("HydraOS: {name}")' in log.read_text(), f'{name} viewer did not connect')
return proc, log
for name in names:
manager.start(name)
media.unlink() # Open QEMU media disappears; status/control/view still work.
time.sleep(1)
before = {name: counter(manager, name) for name in names}
with manager._connect(manager.paths('alpha')) as qmp:
children = qmp.command('qom-list', {'path': '/machine/peripheral-anon'})
video = next(child['name'] for child in children if child['type'] == 'child<virtio-vga>')
mode = {prop: qmp.command('qom-get', {'path': '/machine/peripheral-anon/' + video, 'property': prop})
for prop in ('xres', 'yres', 'refresh_rate')}
assert mode == {'xres': 1920, 'yres': 1080, 'refresh_rate': 144000}, mode
usb_before = qmp.command('human-monitor-command', {'command-line': 'info usb'})
identities = {name: manager.paths(name).pid.read_text().strip() for name in names}
alpha, alog = attach('alpha', 1)
beta, blog = attach('beta', 1)
wait_for(lambda: '.enter(' in alog.read_text(), 'alpha did not receive focus')
# F12 must work before inhibition; then become suppressed in alpha.
keys('down 88\nup 88')
wait_for(unreserved.exists, 'ordinary test binding failed before inhibition')
unreserved.unlink()
chord(23) # I: test-only ToggleKeybinds.
keys('down 88\nup 88')
assert not unreserved.exists(), 'keybinding inhibition did not suppress ordinary F12'
a_leave = alog.read_text().count('.leave(')
chord(106) # Right: production reserved host switch.
wait_for(lambda: alog.read_text().count('.leave(') > a_leave and '.enter(' in blog.read_text(),
'reserved switch failed with inhibition')
chord(105) # Left: return to inhibited alpha.
chord(1) # Escape: production recovery binding still works.
wait_for(recovery.exists, 'reserved recovery failed with inhibition')
dialog = 'set_title("Select USB devices for redirection")'
assert dialog not in alog.read_text(), 'USB selector opened automatically'
# Hold modifiers through opening, then release inside the modal dialog.
keys('down 29\ndown 56\ndown 42\ndown 22')
wait_for(lambda: dialog in alog.read_text(), 'manual USB selector did not open', timeout=3)
keys('up 22\nup 42\nup 56\nup 29')
keys('down 1\nup 1') # Close selector.
chord(106)
chord(105)
assert alpha.poll() is None and beta.poll() is None
assert identities == {name: manager.paths(name).pid.read_text().strip() for name in names}, 'switch restarted a guest'
assert alog.read_text().count('set_title("HydraOS: alpha")') == 1, 'switch reconnected alpha'
assert blog.read_text().count('set_title("HydraOS: beta")') == 1, 'switch reconnected beta'
with manager._connect(manager.paths('alpha')) as qmp:
usb_after = qmp.command('human-monitor-command', {'command-line': 'info usb'})
assert usb_after == usb_before, 'opening selector attached an unselected USB device'
alpha.terminate(); alpha.wait(timeout=5)
assert manager.status('alpha')['status'] == 'running'
alpha, attached = attach('alpha', 2)
after = {name: counter(manager, name) for name in names}
assert all(after[name] != before[name] for name in names), 'background guest stopped working'
# Quit only beta; alpha's counter/lifecycle remains independent.
manager.stop('beta', force=True)
assert manager.status('alpha')['status'] == 'running'
assert counter(manager, 'alpha') != after['alpha']
compositor.terminate(); compositor.wait(timeout=5)
assert manager.status('alpha')['status'] == 'running'
manager.stop('alpha', force=True)
manager.media('alpha', None)
assert manager.profile('alpha').iso is None
manager.start('alpha')
time.sleep(1)
disk_boot_counter = counter(manager, 'alpha')
assert disk_boot_counter > 0
result.update(manual_usb_dialog=True, standard_us_keymap=True,
native_labwc_inhibition=True, reserved_switch_and_recovery=True,
qemu_survives_viewer_and_compositor=True, independent_lifecycle=True,
guest_counters_before=before, guest_counters_after=after,
guest_pids=identities, explicit_reattach=True, requested_qemu_mode=mode,
usb_devices_unchanged_without_selection=True,
missing_iso_does_not_block_views_or_control=True,
restart_without_original_media=True, disk_boot_counter=disk_boot_counter,
viewer_version=subprocess.check_output([args.viewer, '--version'], text=True).strip(),
guest_input_and_physical_modifiers='not proven by this boot counter workload')
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))
finally:
for proc in reversed(processes):
if proc.poll() is None:
proc.terminate()
proc.wait(timeout=5)
if manager.status('probe')['status'] != 'stopped':
manager.stop('probe', force=True)
proc.terminate(); proc.wait(timeout=5)
for stream in streams:
stream.close()
for name in names:
if manager.status(name)['status'] != 'stopped':
manager.stop(name, force=True)
if __name__ == '__main__':