fix: preserve guest network identities and refresh session profiles

This commit is contained in:
Codex
2026-10-13 05:12:04 +03:00
parent e9906a793b
commit ea43a67a97
10 changed files with 493 additions and 26 deletions
+7
View File
@@ -46,3 +46,10 @@ in `/etc/hydra/defaults.toml` affect only new profiles; explicit CLI options win
counter guests and private headless viewers, including manual USB selection, counter guests and private headless viewers, including manual USB selection,
reserved switching under inhibition, recovery and reconnect. See reserved switching under inhibition, recovery and reconnect. See
[verification commands and physical checks still pending](docs/verification.md). [verification commands and physical checks still pending](docs/verification.md).
An empty session opens local recovery. Enter `:shell`, create and start guests
with `hydra add` / `hydra start`, then leave the shell and enter `:refresh`.
Refresh rebuilds workspaces and reconnects viewers for the current profiles;
running VMs keep their processes and disks. Use it after adding or removing
profiles. New profiles save an individual MAC address; legacy profiles obtain
a stable address from their owner and disk path, including on the shared bridge.
+78
View File
@@ -0,0 +1,78 @@
#!/usr/bin/env python3
"""Check real NIC identities on a bridge isolated in user/net/mount namespaces."""
import argparse
import json
from pathlib import Path
import subprocess
import sys
import tempfile
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from hydra.profiles import Profile
from hydra.runtime import Runtime
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--namespace-root', type=Path, help=argparse.SUPPRESS)
args = parser.parse_args()
if args.namespace_root is None:
root = Path(tempfile.mkdtemp(prefix='hydra-network-', dir='/tmp'))
print('Evidence: ' + str(root), flush=True)
(root / 'parent-namespaces.json').write_text(json.dumps({name: str(Path('/proc/self/ns', name).readlink())
for name in ('user', 'net', 'mnt')}))
# All bridge and /etc changes below occur after this explicit namespace
# boundary. The inherited host bridge configuration is never modified.
subprocess.run(['unshare', '-Urnm', '--', str(Path(sys.executable).resolve()),
str(Path(__file__).resolve()), '--namespace-root', str(root)], check=True)
return
root = args.namespace_root
parent_namespaces = json.loads((root / 'parent-namespaces.json').read_text())
namespaces = {name: str(Path('/proc/self/ns', name).readlink()) for name in ('user', 'net', 'mnt')}
assert all(namespaces[name] != parent_namespaces[name] for name in namespaces), 'private namespaces required'
private_etc = root / 'etc'
(private_etc / 'qemu').mkdir(parents=True)
(private_etc / 'qemu/bridge.conf').write_text('allow hydrabr0\n')
subprocess.run(['mount', '--bind', str(private_etc), '/etc'], check=True)
subprocess.run(['ip', 'link', 'add', 'hydrabr0', 'type', 'bridge'], check=True)
subprocess.run(['ip', 'link', 'set', 'hydrabr0', 'up'], check=True)
manager = Runtime(root / 'config', root / 'state', root / 'run')
result = {'evidence': str(root), 'namespace': 'private user/net/mount',
'parent_namespaces': parent_namespaces, 'probe_namespaces': namespaces, 'names': {}}
try:
for name in ('alpha', 'beta'):
manager.add(Profile(name, memory=128, cpus=1, disk_size='8M',
graphics='software', network='bridge'))
manager.start(name)
def actual_mac(name):
with manager._connect(manager.paths(name)) as qmp:
network = qmp.command('human-monitor-command', {'command-line': 'info network'})
mac = manager.profile(name).mac
assert 'macaddr=' + mac in network, network
assert int(mac.split(':')[0], 16) & 3 == 2
return mac, network
for name in ('alpha', 'beta'):
mac, network = actual_mac(name)
result['names'][name] = {'mac': mac, 'network': network}
assert result['names']['alpha']['mac'] != result['names']['beta']['mac']
links = json.loads(subprocess.check_output(['ip', '-j', 'link', 'show', 'master', 'hydrabr0'], text=True))
assert len(links) == 2, links
result['bridge_ports'] = [link['ifname'] for link in links]
manager.stop('alpha', force=True)
manager.start('alpha')
assert actual_mac('alpha')[0] == result['names']['alpha']['mac']
result['restart_preserved_mac'] = True
result['dhcp_and_guest_network_drivers_pending'] = True
result['passed'] = True
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))
finally:
for profile in manager.profiles():
if manager.status(profile.name)['status'] == 'running':
manager.stop(profile.name, force=True)
if __name__ == '__main__':
main()
+196
View File
@@ -0,0 +1,196 @@
#!/usr/bin/env python3
"""Exercise the product's empty-session/recovery/refresh loop privately."""
import argparse
import json
import os
from pathlib import Path
import shutil
import signal
import subprocess
import sys
import tempfile
import time
sys.path.insert(0, str(Path(__file__).resolve().parents[1]))
from hydra.runtime import Runtime
from smoke import (wait_for, counter, focus_events, fresh_focus_transition,
measure_hidden_interval, classify_compositor_errors)
def main():
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument('--labwc', default=os.environ.get('HYDRA_LABWC', 'labwc'))
parser.add_argument('--viewer', default=os.environ.get('HYDRA_VIEWER', 'remote-viewer'))
parser.add_argument('--keyboard', required=True, help='compiled dev/input-probe.c')
args = parser.parse_args()
for name in ('labwc', 'viewer', 'keyboard'):
executable = shutil.which(getattr(args, name))
if not executable:
parser.error('missing executable: ' + getattr(args, name))
setattr(args, name, executable)
if not os.access('/dev/kvm', os.R_OK | os.W_OK):
parser.error('KVM access required')
source = Path(__file__).resolve().parents[1]
root = Path(tempfile.mkdtemp(prefix='hydra-session-refresh-', dir='/tmp'))
print('Evidence: ' + str(root), flush=True)
xdg = root / 'xdg'; xdg.mkdir(mode=0o700)
manager = Runtime(root / 'config', root / 'state', root / 'run')
cli = [sys.executable, str(source / 'bin/hydra'), '--config', str(manager.config),
'--state', str(manager.state), '--runtime', str(manager.runtime)]
subprocess.run(['as', '--32', str(source / 'dev/guest-probe.S'), '-o', str(root / 'boot.o')], check=True)
subprocess.run(['objcopy', '-j', '.text', '-O', 'binary', str(root / 'boot.o'), str(root / 'boot.bin')], check=True)
(root / 'boot.raw').write_bytes((root / 'boot.bin').read_bytes() + bytes(8 * 1024 * 1024 - 512))
def wrapper(name, code):
path = root / name
path.write_text('#!' + sys.executable + '\n' + code)
path.chmod(0o700)
return str(path)
common = 'import json, os, pathlib, subprocess, sys, time\nroot = pathlib.Path(' + repr(str(root)) + ')\n'
labwc = wrapper('labwc-private', common + '''
if '--version' in sys.argv:
os.execv(@LABWC@, [@LABWC@] + sys.argv[1:])
config = pathlib.Path(sys.argv[sys.argv.index('-C') + 1])
number = len(list(root.glob('snapshot-*.json')))
data = json.loads((config.parent / 'session.json').read_text())
data.update(pid=os.getpid(), config_directory=str(config))
(root / ('snapshot-%d.xml' % number)).write_text((config / 'rc.xml').read_text())
(root / ('snapshot-%d.json' % number)).write_text(json.dumps(data))
# Test-only wrapper replaces physical backends before the real compositor runs.
os.environ.update(WLR_BACKENDS='headless', WLR_RENDERER='pixman',
WLR_XWAYLAND='/bin/false', LABWC_UPDATE_ACTIVATION_ENV='0',
XDG_DATA_DIRS='/nonexistent')
os.execv(@LABWC@, [@LABWC@, '-d'] + sys.argv[1:])
'''.replace('@LABWC@', repr(args.labwc)))
viewer = wrapper('viewer-private', common + '''
if '--help' in sys.argv:
os.execv(VIEWER, [VIEWER] + sys.argv[1:])
name = sys.argv[sys.argv.index('--title') + 1].split(': ', 1)[1]
(root / (name + '-viewer.json')).write_text(json.dumps({
'argv': sys.argv[1:], 'config': os.environ['XDG_CONFIG_HOME'],
'gdk_backend': os.environ.get('GDK_BACKEND'), 'wayland_display': os.environ['WAYLAND_DISPLAY']}))
log = os.open(root / (name + '.log'), os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
os.dup2(log, 1); os.dup2(log, 2)
os.environ['WAYLAND_DEBUG'] = '1'
os.execv(VIEWER, [VIEWER] + sys.argv[1:])
'''.replace('VIEWER', repr(args.viewer)))
terminal = wrapper('terminal-private', common + '''
manifest = pathlib.Path(sys.argv[-1])
socket = pathlib.Path(os.environ['XDG_RUNTIME_DIR']) / os.environ['WAYLAND_DISPLAY']
(root / 'recovery-ready').write_text(str(manifest))
while not (root / 'recovery-input').exists():
if not socket.exists():
sys.exit(0)
time.sleep(.05)
if not socket.exists():
sys.exit(0)
commands = (root / 'recovery-input').read_text()
# The actual product recovery helper executes :shell and :refresh.
with (root / 'recovery.log').open('a') as log:
sys.exit(subprocess.run(sys.argv[2:], input=commands, text=True, stdout=log, stderr=log).returncode)
''')
shell = wrapper('scratch-shell', common + 'sys.path.insert(0, ' + repr(str(source)) + ')\n' + '''
from hydra.runtime import Runtime
from smoke import counter, wait_for
cli = CLI
manager = Runtime(root / 'config', root / 'state', root / 'run')
for name in ('alpha', 'beta'):
subprocess.run(cli + ['add', name, '--memory', '128', '--cpus', '1', '--disk-size', '8M',
'--graphics', 'software', '--network', 'user'], check=True)
paths = manager.paths(name); manager.prepare(paths)
subprocess.run([os.environ['HYDRA_QEMU_IMG'], 'convert', '-f', 'raw', '-O', 'qcow2',
str(root / 'boot.raw'), str(paths.disk)], check=True)
subprocess.run(cli + ['start', name], check=True)
time.sleep(.3)
identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)}
for name in ('alpha', 'beta')}
(root / 'before.json').write_text(json.dumps(identities))
'''.replace('CLI', repr(cli)).replace('from smoke import', 'from dev.smoke import'))
env = dict(os.environ, XDG_RUNTIME_DIR=str(xdg), HYDRA_LABWC=labwc,
HYDRA_VIEWER=viewer, HYDRA_TERMINAL=terminal, HYDRA_BRIGHTNESS=shutil.which('true'),
SHELL=shell, GSETTINGS_BACKEND='memory')
for key in ('WAYLAND_DISPLAY', 'DISPLAY', 'LABWC_PID'):
env.pop(key, None)
log = (root / 'session.log').open('w')
launcher, injector = None, None
result = {'evidence': str(root), 'physical_input_pending': True,
'terminal': 'test-only command feeder; real recovery helper and scratch :shell',
'backend': 'test-only wrapper around actual product launch_session and actual labwc'}
try:
launcher = subprocess.Popen(cli + ['session'], env=env, stdout=log, stderr=log)
wait_for(lambda: (root / 'recovery-ready').exists(), 'empty session did not open recovery')
first = json.loads((root / 'snapshot-0.json').read_text())
assert first['names'] == [], 'session was not initially empty'
(root / 'recovery-input').write_text(':shell\n:refresh\n')
wait_for(lambda: (root / 'snapshot-1.json').exists(), 'product loop did not restart after :refresh', timeout=15)
second = json.loads((root / 'snapshot-1.json').read_text())
assert second['names'] == ['alpha', 'beta'], second
assert first['pid'] != second['pid'], 'compositor was not refreshed'
logs = {name: root / (name + '.log') for name in ('alpha', 'beta')}
for name, path in logs.items():
wait_for(lambda: path.exists() and 'set_title("HydraOS: ' + name + '")' in path.read_text(),
name + ' product viewer did not connect')
viewers = {name: json.loads((root / (name + '-viewer.json')).read_text()) for name in logs}
assert len({data['config'] for data in viewers.values()}) == 2, 'viewer settings were shared'
for data in viewers.values():
assert data['gdk_backend'] == 'wayland'
assert data['config'].startswith(str(manager.runtime) + '/viewer-')
assert '--spice-usbredir-auto-redirect-filter=-1,-1,-1,-1,0' in data['argv']
assert '--spice-usbredir-redirect-on-connect=-1,-1,-1,-1,0' in data['argv']
alog, blog = logs['alpha'], logs['beta']
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
keyboard_env = dict(env, WAYLAND_DISPLAY=display)
injector = subprocess.Popen([args.keyboard], env=keyboard_env, stdin=subprocess.PIPE, text=True)
time.sleep(.2)
wait_for(lambda: focus_events(alog) and focus_events(alog)[-1] == 'enter', 'alpha did not focus')
def switch(code, source_log, target_log):
before_source, before_target = focus_events(source_log), focus_events(target_log)
injector.stdin.write(f'down 29\ndown 56\ndown 42\ndown {code}\nup {code}\nup 42\nup 56\nup 29\nwait 100\n')
injector.stdin.flush()
wait_for(lambda: fresh_focus_transition(before_source, before_target,
focus_events(source_log), focus_events(target_log)), 'fresh reserved focus transition failed')
switch(106, alog, blog)
intervals = {'alpha': measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)}
switch(105, blog, alog)
intervals['beta'] = measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)
before = json.loads((root / 'before.json').read_text())
after = {name: {'pid': manager.paths(name).pid.read_text().strip(),
'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)}
for name in ('alpha', 'beta')}
for name in before:
assert after[name]['pid'] == before[name]['pid'] and after[name]['uuid'] == before[name]['uuid']
assert after[name]['counter'] != before[name]['counter']
# Only the PID recorded by our own wrapper, verified against its private
# config path, may be signalled. Never inherit a host compositor PID.
command = Path('/proc') / str(second['pid']) / 'cmdline'
assert second['config_directory'].encode() in command.read_bytes()
os.kill(second['pid'], signal.SIGTERM)
assert launcher.wait(timeout=10) == 0, 'product session exit failed'
for name in before:
assert manager.status(name)['status'] == 'running'
assert manager.paths(name).pid.read_text().strip() == before[name]['pid']
result.update(initial_names=first['names'], refreshed_names=second['names'],
compositor_pids=[first['pid'], second['pid']], before=before, after=after,
viewers=viewers,
hidden_intervals=intervals, survived_session_exit=True,
expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()),
passed=True)
(root / 'result.json').write_text(json.dumps(result, indent=2) + '\n')
print(json.dumps(result, indent=2))
finally:
if injector and injector.poll() is None:
injector.terminate(); injector.wait(timeout=5)
if launcher and launcher.poll() is None:
launcher.send_signal(signal.SIGINT); launcher.wait(timeout=12)
for profile in manager.profiles():
if manager.status(profile.name)['status'] == 'running':
manager.stop(profile.name, force=True)
log.close()
if __name__ == '__main__':
main()
+44
View File
@@ -12,6 +12,8 @@ python3 -m unittest discover -s tests -v
nix-build dev/tools.nix -A viewer --no-out-link nix-build dev/tools.nix -A viewer --no-out-link
nix-build dev/tools.nix -A qemu --no-out-link nix-build dev/tools.nix -A qemu --no-out-link
nix-shell dev/tools.nix --run 'python3 dev/smoke.py' nix-shell dev/tools.nix --run 'python3 dev/smoke.py'
nix-shell dev/tools.nix --run 'python3 dev/session-refresh-smoke.py --keyboard "$(dev/build-input-probe.sh)"'
nix-shell dev/tools.nix --run 'python3 dev/check-network.py'
``` ```
The smoke test builds a private input helper with an ordinary evdev/pc105 US The smoke test builds a private input helper with an ordinary evdev/pc105 US
@@ -53,6 +55,48 @@ suppress viewer errors or compiler/test diagnostics. The viewer build's upstream
Meson boolean-check and compiler-warning-option guidance are build noise; the four-test Meson boolean-check and compiler-warning-option guidance are build noise; the four-test
result, compiler errors and process exit status remain the build acceptance gate. result, compiler errors and process exit status remain the build acceptance gate.
## Fresh-session recovery and profile changes
A session with no profiles opens recovery with first-use guidance. Enter `:shell`
to run `hydra add NAME --iso PATH` and `hydra start NAME`; leave the shell and enter
`:refresh`. After adding or removing profiles, refresh reloads the profile list,
rebuilds workspace rules, restarts only the session's own compositor, and attaches
new isolated viewers to running guests. QEMU processes and workloads survive.
The recovery prompt can then reconnect the new names; reserved Left/Right chords
switch between their workspaces. `:quit` closes the recovery prompt.
`dev/session-refresh-smoke.py` exercises the actual product launch loop, recovery
helper, `:shell` and `:refresh`, starting with an empty profile directory. A
test-only labwc wrapper selects private headless backends; a terminal command
feeder drives recovery and a scratch shell adds and starts two counter guests.
Actual labwc and native Wayland viewers then demonstrate fresh keyboard focus
transitions in both directions, hidden workload continuity, unchanged QEMU
PIDs/UUIDs through refresh, and guest survival after normal compositor exit.
This checks the recovery command flow, not physical terminal rendering/usability.
It leaves snapshots, recovery output, protocol logs and `result.json` in
`/tmp/hydra-session-refresh-*` and stops only its verified scratch QEMU processes.
## Bridge identities
New profiles persist a locally administered unicast `mac` in their TOML file.
It remains stable across stop/start, media changes and runtime socket-directory
changes. The generated address incorporates the VM disk path and user identity,
so separate profiles/users avoid QEMU's identical default MAC on a shared bridge.
Existing profiles without `mac` use the same deterministic calculation without
rewriting their file. Relocating a legacy disk or changing its owner changes
that fallback; explicitly persisting its current address retains the identity.
Explicit TOML addresses must also be locally administered unicast, and users
copying profiles must assign distinct addresses to concurrently used copies.
`dev/check-network.py` requires unprivileged user/network/mount namespaces. It
asserts all three namespace identities changed before creating a private
`hydrabr0` and mounting a private QEMU bridge-helper ACL over `/etc` within that
namespace. Two actual scratch QEMUs must report different configured NIC MACs,
both bridge TAP ports must exist, and restart must preserve the first guest's
address. Evidence remains under `/tmp/hydra-network-*`; host bridges, host `/etc`
and existing guests are unchanged. This does not prove guest network drivers,
DHCP leases or external connectivity; those need the installed-guest harness.
## Installed guest workflow ## Installed guest workflow
`--iso` is optional. Attach or detach media only while the VM is stopped: `--iso` is optional. Attach or detach media only while the VM is stopped:
+7
View File
@@ -26,6 +26,7 @@ class Profile:
width: int = 1280 width: int = 1280
height: int = 800 height: int = 800
refresh_millihz: int = 0 refresh_millihz: int = 0
mac: str | None = None
def __post_init__(self): def __post_init__(self):
validate_name(self.name) validate_name(self.name)
@@ -33,6 +34,12 @@ class Profile:
if not isinstance(self.iso, (str, Path)) or not str(self.iso): if not isinstance(self.iso, (str, Path)) or not str(self.iso):
raise ValueError('ISO must be a nonempty path or omitted') raise ValueError('ISO must be a nonempty path or omitted')
object.__setattr__(self, 'iso', Path(self.iso).expanduser().resolve()) object.__setattr__(self, 'iso', Path(self.iso).expanduser().resolve())
if self.mac is not None:
if (not isinstance(self.mac, str) or
not re.fullmatch(r'[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}', self.mac) or
int(self.mac[:2], 16) & 3 != 2):
raise ValueError('mac must be a locally administered unicast Ethernet address')
object.__setattr__(self, 'mac', self.mac.lower())
for key, upper in (('memory', 262144), ('cpus', 256)): for key, upper in (('memory', 262144), ('cpus', 256)):
value = getattr(self, key) value = getattr(self, key)
if type(value) is not int or not 1 <= value <= upper: if type(value) is not int or not 1 <= value <= upper:
+12 -1
View File
@@ -3,6 +3,7 @@
import contextlib import contextlib
import errno import errno
import fcntl import fcntl
import hashlib
import json import json
import os import os
import socket import socket
@@ -54,6 +55,14 @@ def _intel_render_node() -> Path | None:
return None return None
def _stable_mac(paths: VMPaths) -> str:
# Disk/state identity survives restarts and runtime socket-directory changes.
# UID distinguishes owners even when a shared bridge sees the same names.
digest = bytearray(hashlib.sha256(f'{os.getuid()}:{paths.disk}'.encode()).digest()[:6])
digest[0] = (digest[0] & 0xfc) | 0x02
return ':'.join(f'{octet:02x}' for octet in digest)
def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]: def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg' accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg'
argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name, argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name,
@@ -78,7 +87,7 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
argv += ['-device', 'virtio-blk-pci,drive=disk'] argv += ['-device', 'virtio-blk-pci,drive=disk']
if profile.iso is not None: if profile.iso is not None:
argv += ['-device', 'ide-cd,drive=iso'] argv += ['-device', 'ide-cd,drive=iso']
argv += ['-boot', 'order=dc' if profile.iso is not None else 'order=c', '-device', 'virtio-net-pci,netdev=net', argv += ['-boot', 'order=dc' if profile.iso is not None else 'order=c', '-device', f'virtio-net-pci,netdev=net,mac={profile.mac or _stable_mac(paths)}',
'-netdev', 'user,id=net' if profile.network == 'user' '-netdev', 'user,id=net' if profile.network == 'user'
else f'bridge,id=net,br={profile.bridge}', else f'bridge,id=net,br={profile.bridge}',
'-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd'] '-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd']
@@ -136,6 +145,8 @@ class Runtime:
def add(self, profile: Profile) -> None: def add(self, profile: Profile) -> None:
self._validate_media(profile.iso) self._validate_media(profile.iso)
_private_dir(self.config) _private_dir(self.config)
if profile.mac is None:
profile = replace(profile, mac=_stable_mac(self.paths(profile.name)))
save_profile(self.config / f'{profile.name}.toml', profile) save_profile(self.config / f'{profile.name}.toml', profile)
@staticmethod @staticmethod
+37 -6
View File
@@ -49,6 +49,7 @@ def build_labwc_config(names: list[str], recovery_argv: list[str],
core = ET.SubElement(root, 'core') core = ET.SubElement(root, 'core')
ET.SubElement(core, 'primarySelection').text = 'no' ET.SubElement(core, 'primarySelection').text = 'no'
desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names)))) desktops = ET.SubElement(root, 'desktops', number=str(max(1, len(names))))
if names:
labels = ET.SubElement(desktops, 'names') labels = ET.SubElement(desktops, 'names')
for name in names: for name in names:
ET.SubElement(labels, 'name').text = name ET.SubElement(labels, 'name').text = name
@@ -173,9 +174,10 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout) match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0): if version.returncode or not match or tuple(map(int, match.groups())) < (0, 20, 0):
raise SessionError('HydraOS requires labwc >= 0.20.0 for reserved host input bindings') raise SessionError('HydraOS requires labwc >= 0.20.0 for reserved host input bindings')
_private_dir(runtime.runtime)
while True:
profiles = runtime.profiles() profiles = runtime.profiles()
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None) initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
_private_dir(runtime.runtime)
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp: with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
directory = Path(tmp) directory = Path(tmp)
config = directory / 'labwc' config = directory / 'labwc'
@@ -188,10 +190,29 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
_recovery_argv(manifest, terminal), brightness, _recovery_argv(manifest, terminal), brightness,
initial=initial), encoding='utf-8') initial=initial), encoding='utf-8')
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8') (config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
# Empty environment files prevent loading unrelated /etc/xdg defaults.
(config / 'environment').touch() (config / 'environment').touch()
env['PYTHONUNBUFFERED'] = '1' env['PYTHONUNBUFFERED'] = '1'
return subprocess.run([labwc, '-C', str(config)], env=env).returncode compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
refresh = False
try:
while True:
if (directory / 'refresh.request').exists():
refresh = True
break
if compositor.poll() is not None:
# A requesting recovery terminal can exit before polling.
refresh = (directory / 'refresh.request').exists()
break
time.sleep(.1)
finally:
if compositor.poll() is None:
compositor.terminate()
compositor.wait(timeout=10)
if not refresh:
return compositor.returncode
# Only our compositor was stopped. Its viewers disconnect; detached
# QEMU instances retain their identity and running workloads.
def session_helper(command: str, manifest: Path) -> int: def session_helper(command: str, manifest: Path) -> int:
@@ -203,14 +224,22 @@ def session_helper(command: str, manifest: Path) -> int:
raise SessionError('Invalid session manifest') raise SessionError('Invalid session manifest')
for name in data['names']: for name in data['names']:
validate_name(name) validate_name(name)
if command not in ('clients', 'recover') and ( if command not in ('clients', 'recover', 'refresh') and (
not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']): not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']):
raise SessionError('Invalid session helper operation') raise SessionError('Invalid session helper operation')
runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime'])) runtime = Runtime(Path(data['config']), Path(data['state']), Path(data['runtime']))
if command == 'refresh':
fd = os.open(manifest.parent / 'refresh.request', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
os.close(fd)
print('Refreshing workspaces and viewers; VMs keep running.')
return 0
if command == 'recover': if command == 'recover':
print('HydraOS local recovery. VMs keep running independently.\n' print('HydraOS local recovery. VMs keep running independently.\n'
'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n' 'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n'
'Enter a VM name to reconnect, :shell for a host shell, :quit to close this terminal.') 'Enter a VM name to reconnect, :shell for a host shell, :refresh to rebuild workspaces/viewers, or :quit.')
if not data['names']:
print('No VM profiles yet. Use :shell to run hydra add NAME --iso PATH and hydra start NAME.\n'
'After adding or removing profiles, leave the shell and enter :refresh; running VMs are preserved.')
while True: while True:
try: try:
print(json.dumps([runtime.status(name) for name in data['names']], indent=2)) print(json.dumps([runtime.status(name) for name in data['names']], indent=2))
@@ -219,12 +248,14 @@ def session_helper(command: str, manifest: Path) -> int:
return 0 return 0
if choice == ':quit': if choice == ':quit':
return 0 return 0
if choice == ':refresh':
return session_helper('refresh', manifest)
if choice == ':shell': if choice == ':shell':
subprocess.run([os.environ.get('SHELL', '/bin/sh')]) subprocess.run([os.environ.get('SHELL', '/bin/sh')])
elif choice in data['names']: elif choice in data['names']:
subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True) subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True)
else: else:
print('Enter a configured VM name, :shell, or :quit.') print('Enter a configured VM name, :shell, :refresh, or :quit.')
if command.startswith('reconnect:'): if command.startswith('reconnect:'):
name = command.split(':', 1)[1] name = command.split(':', 1)[1]
# One viewer per VM inside this session; independent host view commands # One viewer per VM inside this session; independent host view commands
+9
View File
@@ -65,6 +65,15 @@ class ProfilesTests(unittest.TestCase):
with self.subTest(field=field, value=value), self.assertRaises(ValueError): with self.subTest(field=field, value=value), self.assertRaises(ValueError):
Profile('guest', self.iso, **{field: value}) Profile('guest', self.iso, **{field: value})
def test_explicit_mac_roundtrip_and_validation(self):
dest = self.root / 'guest.toml'
save_profile(dest, Profile('guest', mac='02:ab:cd:ef:01:23'))
self.assertEqual(load_profile(dest).mac, '02:ab:cd:ef:01:23')
for value in ('ff:00:00:00:00:01', '00:00:00:00:00:01',
'02:00:00:00:00:zz', '02:00:00:00:00', True):
with self.subTest(mac=value), self.assertRaises(ValueError):
Profile('guest', mac=value)
def test_refresh_vertical_descriptor_boundaries(self): def test_refresh_vertical_descriptor_boundaries(self):
for value in (0, 50000, 255000): for value in (0, 50000, 255000):
path = self.root / 'guest.toml' path = self.root / 'guest.toml'
+41 -1
View File
@@ -88,6 +88,46 @@ class RuntimeTests(unittest.TestCase):
except VMError: except VMError:
pass pass
def test_mac_persisted_distinct_and_stable_across_restarts(self):
self.runtime.add(Profile('other', network='bridge'))
other = self.runtime.profile('other')
guest = self.runtime.profile('guest')
self.assertIsNotNone(guest.mac)
self.assertNotEqual(guest.mac, other.mac)
octet = int(guest.mac.split(':')[0], 16)
self.assertEqual(octet & 3, 2) # locally administered, unicast
def nic(profile, runtime):
return next(arg for arg in build_qemu_argv(profile, runtime.paths(profile.name))
if arg.startswith('virtio-net-pci,'))
expected = nic(guest, self.runtime)
self.runtime.start('guest')
self.runtime.stop('guest', force=True)
self.runtime.start('guest')
self.runtime.stop('guest', force=True)
self.assertEqual(nic(self.runtime.profile('guest'), self.runtime), expected)
changed_runtime = Runtime(self.runtime.config, self.runtime.state, self.root / 'different-run')
self.assertEqual(nic(changed_runtime.profile('guest'), changed_runtime), expected)
self.runtime.media('guest', None)
self.assertEqual(self.runtime.profile('guest').mac, guest.mac)
def test_legacy_profiles_gain_stable_owner_specific_argv_mac(self):
file = self.runtime.config / 'legacy.toml'
save_profile(file, Profile('legacy', network='bridge'))
original = file.read_bytes()
legacy = self.runtime.profile('legacy')
self.assertIsNone(legacy.mac)
paths = self.runtime.paths('legacy')
def mac():
return next(arg for arg in build_qemu_argv(legacy, paths) if arg.startswith('virtio-net-pci,'))
with patch('os.getuid', return_value=1000):
first = mac()
self.assertEqual(mac(), first)
self.assertNotEqual(first, next(arg for arg in build_qemu_argv(
legacy, self.runtime.paths('other')) if arg.startswith('virtio-net-pci,')))
with patch('os.getuid', return_value=1001):
self.assertNotEqual(mac(), first)
self.assertEqual(file.read_bytes(), original)
def test_disk_boot_omits_media_and_requests_display(self): def test_disk_boot_omits_media_and_requests_display(self):
profile = Profile('guest', width=1920, height=1080, refresh_millihz=144000, profile = Profile('guest', width=1920, height=1080, refresh_millihz=144000,
graphics='software') graphics='software')
@@ -145,7 +185,7 @@ class RuntimeTests(unittest.TestCase):
self.assertIn('disable-agent-file-xfer=on', spice) self.assertIn('disable-agent-file-xfer=on', spice)
self.assertIn('run,,comma', spice) self.assertIn('run,,comma', spice)
self.assertIn('virtio-blk-pci,drive=disk', argv) self.assertIn('virtio-blk-pci,drive=disk', argv)
self.assertIn('virtio-net-pci,netdev=net', argv) self.assertTrue(any(arg.startswith('virtio-net-pci,netdev=net,mac=') for arg in argv))
self.assertNotIn('-vnc', argv) self.assertNotIn('-vnc', argv)
self.assertNotIn('-virtfs', argv) self.assertNotIn('-virtfs', argv)
+44
View File
@@ -55,6 +55,11 @@ class SessionTests(unittest.TestCase):
self.assertEqual(tree.findall('./mouse/context/mousebind/action'), []) self.assertEqual(tree.findall('./mouse/context/mousebind/action'), [])
self.assertEqual(tree.findtext('./libinput/device/tap'), 'yes') self.assertEqual(tree.findtext('./libinput/device/tap'), 'yes')
def test_empty_session_has_one_workspace_without_empty_names(self):
tree = ET.fromstring(session.build_labwc_config([], ['foot']))
self.assertEqual(tree.find('./desktops').get('number'), '1')
self.assertIsNone(tree.find('./desktops/names'))
def test_viewer_argv_is_private_safe_and_manual_usb_only(self): def test_viewer_argv_is_private_safe_and_manual_usb_only(self):
paths = self.manager.paths('alpha') paths = self.manager.paths('alpha')
argv = session.build_viewer_argv(paths, '/tool path/viewer') argv = session.build_viewer_argv(paths, '/tool path/viewer')
@@ -173,6 +178,45 @@ else:
self.assertEqual(main(['_session-helper', 'recover', str(manifest)]), 1) self.assertEqual(main(['_session-helper', 'recover', str(manifest)]), 1)
self.assertIn('manifest', error.getvalue().lower()) self.assertIn('manifest', error.getvalue().lower())
def test_empty_launch_refresh_rebuilds_profile_snapshot(self):
manager = Runtime(self.root / 'empty-config', self.root / 'empty-state', self.root / 'empty-run')
capture = self.root / 'refresh-capture.json'
compositor = self.root / 'refresh-labwc'
compositor.write_text('#!' + os.sys.executable + '\n' + r'''import json, os, subprocess, sys
from pathlib import Path
if '--version' in sys.argv:
print('labwc 0.20.2')
sys.exit(0)
config = Path(sys.argv[sys.argv.index('-C') + 1])
manifest = config.parent / 'session.json'
data = json.loads(manifest.read_text())
capture = Path(os.environ['CAPTURE'])
seen = json.loads(capture.read_text()) if capture.exists() else []
seen.append({'names': data['names'], 'xml': (config / 'rc.xml').read_text()})
capture.write_text(json.dumps(seen))
if len(seen) == 1:
launcher = os.environ['HYDRA_SOURCE_LAUNCHER']
for name in ('first', 'second'):
subprocess.run([sys.executable, launcher, '--config', data['config'], '--state', data['state'],
'--runtime', data['runtime'], 'add', name], check=True)
subprocess.run([sys.executable, launcher, '_session-helper', 'recover', str(manifest)],
input=':refresh\n', text=True, check=True)
sys.exit(67)
sys.exit(0)
''')
compositor.chmod(0o700)
with patch.dict(os.environ, {'HYDRA_LABWC': str(compositor), 'HYDRA_TERMINAL': str(compositor),
'HYDRA_BRIGHTNESS': str(compositor), 'CAPTURE': str(capture),
'HYDRA_SOURCE_LAUNCHER': str(Path('bin/hydra').resolve())}), contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(session.launch_session(manager), 0)
seen = json.loads(capture.read_text())
self.assertEqual([entry['names'] for entry in seen], [[], ['first', 'second']])
tree = ET.fromstring(seen[1]['xml'])
self.assertEqual(tree.find('desktops').get('number'), '2')
self.assertEqual([rule.get('title') for rule in tree.findall('./windowRules/windowRule')],
['HydraOS: first', 'HydraOS: second'])
self.assertFalse(manager.paths('first').disk.exists())
def test_no_running_vms_opens_recovery_without_start_or_stop(self): def test_no_running_vms_opens_recovery_without_start_or_stop(self):
terminal = self.root / 'terminal' terminal = self.root / 'terminal'
capture = self.root / 'recovery-argv.json' capture = self.root / 'recovery-argv.json'