Add terminal VM menu and focused session handoff

This commit is contained in:
Codex
2026-10-13 15:18:03 +03:00
parent b547d6c535
commit c6099103b2
5 changed files with 675 additions and 65 deletions
+28 -9
View File
@@ -130,7 +130,7 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
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(),
wait_for(lambda: path.exists() and 'set_title("Hydra OS: ' + 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'
@@ -141,10 +141,28 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
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'))
# Exercise a real menu selection of the second, already running VM.
# Its request refreshes only the private compositor/viewers.
before_beta_focus = len(focus_events(blog))
manifest = Path(second['config_directory']).parent / 'session.json'
with (root / 'selection.log').open('w') as selection_log:
selection = subprocess.run(cli + ['_session-helper', 'recover', str(manifest)],
input='beta\n', text=True,
env=dict(env, WAYLAND_DISPLAY=display),
stdout=selection_log, stderr=selection_log, timeout=20)
assert selection.returncode == 0, 'menu selection failed'
wait_for(lambda: (root / 'snapshot-2.json').exists(), 'selected menu did not refresh compositor', timeout=15)
third = json.loads((root / 'snapshot-2.json').read_text())
from xml.etree import ElementTree as ET
assert ET.fromstring((root / 'snapshot-2.xml').read_text()).findtext('./desktops/initial') == '2'
wait_for(lambda: any(p.name.startswith('wayland-') and not p.name.endswith('.lock')
for p in xdg.iterdir()), 'refreshed private display is not ready')
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')
wait_for(lambda: len(focus_events(blog)) > before_beta_focus and focus_events(blog)[-1] == 'enter',
'second VM did not gain fresh focus after menu selection')
def switch(code, source_log, target_log):
before_source, before_target = focus_events(source_log), focus_events(target_log)
@@ -153,10 +171,10 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
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)
intervals = {'beta': measure_hidden_interval(lambda: counter(manager, 'beta'), blog, alog)}
switch(106, alog, blog)
intervals['alpha'] = measure_hidden_interval(lambda: counter(manager, 'alpha'), alog, blog)
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)}
@@ -166,15 +184,16 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
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)
command = Path('/proc') / str(third['pid']) / 'cmdline'
assert third['config_directory'].encode() in command.read_bytes()
os.kill(third['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,
compositor_pids=[first['pid'], second['pid'], third['pid']],
selected_name='beta', selected_workspace=2, before=before, after=after,
viewers=viewers,
hidden_intervals=intervals, survived_session_exit=True,
expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()),
+246
View File
@@ -0,0 +1,246 @@
"""Readable terminal control; displaying this menu never starts guests."""
import json
import os
from pathlib import Path
import shlex
import shutil
import subprocess
import tempfile
import time
from .profiles import load_profile, validate_name
from .runtime import Runtime, RuntimeError, _private_dir
from .session import request_refresh
def open_vm(runtime, name, **kwargs):
from .launch import open_vm as launch
return launch(runtime, name, **kwargs)
def run_source(runtime, source, **kwargs):
from .launch import run_source as launch
return launch(runtime, source, **kwargs)
def download_iso(url, **kwargs):
from .media import download_iso as download
return download(url, **kwargs)
def _safe(value) -> str:
# Profile errors and terminal input can contain control sequences.
return ''.join(char if char.isprintable() else '?' for char in str(value))
def _last_selected(runtime: Runtime) -> str | None:
try:
data = json.loads((runtime.state / 'menu.json').read_text(encoding='utf-8'))
return validate_name(data['last_selected'])
except (OSError, ValueError, TypeError, KeyError):
return None
def _remember(runtime: Runtime, name: str) -> None:
_private_dir(runtime.state)
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=runtime.state,
prefix='.menu-', delete=False) as stream:
temporary = Path(stream.name)
try:
json.dump({'last_selected': validate_name(name)}, stream)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, runtime.state / 'menu.json')
finally:
temporary.unlink(missing_ok=True)
def _profiles(runtime: Runtime):
profiles = []
for path in sorted(runtime.config.glob('*.toml')):
try:
profiles.append(load_profile(path))
except (OSError, ValueError) as exc:
print(f'Profile error — {_safe(path.name)}: {_safe(exc)}; repair with :shell.')
return profiles
def _render(runtime: Runtime, profiles, last: str | None):
print('\nHydra OS — virtual machines\n')
if not profiles:
print('No VM profiles. Use :run to create one from an ISO path or direct URL.')
else:
print(f' # {"Name":<32} {"State":<16} RAM')
for number, profile in enumerate(profiles, 1):
try:
status = runtime.status(profile.name)['status']
except (RuntimeError, OSError, ValueError) as exc:
status = 'error'
print(f' {_safe(profile.name)}: {_safe(exc)}')
marker = '*' if profile.name == last else ' '
print(f'{marker}{number:>2} {profile.name:<32} {_safe(status):<16} {profile.memory} MiB')
print('\nNumber/name opens one VM; Enter opens the last (*) or only VM.')
print(':run / :new :download :network :shell :help :refresh :quit')
print(':shutdown NAME requests guest shutdown; :detach NAME removes ISO while stopped.')
def _preview(argv: list[str]):
print('Command: ' + _safe(shlex.join(argv)))
def _source_prompt(label: str, supplied: str | None = None) -> str:
value = supplied if supplied is not None else input(label).strip()
if not value:
raise ValueError('Action cancelled; no changes made.')
return value
def _guided_run(runtime: Runtime, manifest: Path | None, source: str | None):
source = _source_prompt('ISO path or direct HTTP(S) URL (blank cancels)> ', source)
name = validate_name(_source_prompt('New VM name (blank cancels)> '))
# Reject duplicates before asking for options or downloading an image.
if (runtime.config / (name + '.toml')).exists():
raise ValueError(f'{name}: profile exists; enter its name or use hydra open {name}.')
memory = int(input('RAM in MiB [4096]> ').strip() or '4096')
cpus = int(input('CPUs [2]> ').strip() or '2')
disk_size = input('Disk size [40G]> ').strip() or '40G'
sha256 = input('SHA-256 (optional)> ').strip() or None
_preview(['hydra', 'run', _redact_url(source), '--name', name, '--memory', str(memory),
'--cpus', str(cpus), '--disk-size', disk_size] + (['--sha256', sha256] if sha256 else []))
code = run_source(runtime, source, name=name, memory=memory, cpus=cpus,
disk_size=disk_size, sha256=sha256, manifest=manifest,
defaults=Path('/etc/hydra/defaults.toml'), progress=_progress())
return code, name
def _progress():
previous = -1.0
def report(received: int, total: int | None):
nonlocal previous
now = time.monotonic()
if previous >= 0 and now - previous < 1 and received != total:
return
previous = now
amount = f'{received / 1048576:.1f}'
if total is not None:
amount += f' / {total / 1048576:.1f}'
print(f'Download: {amount} MiB', flush=True)
return report
def _redact_url(source: str) -> str:
from urllib.parse import urlsplit, urlunsplit
try:
parsed = urlsplit(source)
if parsed.scheme.lower() in ('http', 'https'):
# Query strings and credentials must never appear in previews.
return urlunsplit((parsed.scheme, parsed.hostname or '', parsed.path,
'redacted' if parsed.query else '', ''))
except ValueError:
return '<invalid URL>'
return source
def run_menu(runtime: Runtime, *, manifest: Path | None = None) -> int:
last = _last_selected(runtime)
while True:
profiles = _profiles(runtime)
names = [profile.name for profile in profiles]
_render(runtime, profiles, last)
try:
choice = input('hydra> ').strip()
except (EOFError, KeyboardInterrupt):
print()
return 0
try:
if not choice:
choice = last if last in names else (names[0] if len(names) == 1 else '')
if choice in names or choice.isdecimal():
name = choice
if choice.isdecimal():
index = int(choice) - 1
if not 0 <= index < len(names):
raise ValueError('Choose a listed VM number or name.')
name = names[index]
_preview(['hydra', 'open', name])
code = open_vm(runtime, name, manifest=manifest)
if code == 0:
try:
_remember(runtime, name)
except (OSError, RuntimeError) as exc:
print(f'Could not remember selection: {_safe(exc)}')
last = name
if manifest is not None:
return 0
else:
print(f'{name}: open returned {code}; retry or use :shell for diagnostics.')
continue
command = shlex.split(choice)
action = command[0] if command else ''
arguments = command[1:]
if action in (':quit', ':help', ':shell', ':network', ':refresh') and arguments:
raise ValueError(f'{action} takes no arguments.')
if action == ':quit':
return 0
if action == ':shell':
_preview([os.environ.get('SHELL', '/bin/sh')])
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
elif action == ':network':
nmtui = shutil.which('nmtui')
sudo = shutil.which('sudo')
if not nmtui:
raise ValueError('Network setup needs nmtui (NetworkManager); use :shell to inspect networking.')
argv = [nmtui] if os.geteuid() == 0 else [sudo, nmtui] if sudo else None
if argv is None:
raise ValueError('Network setup needs sudo; use :shell to inspect networking.')
_preview(argv)
result = subprocess.run(argv)
if result.returncode:
print(f'Network setup exited with {result.returncode}; menu remains available.')
elif action == ':refresh':
if manifest is not None:
return request_refresh(manifest)
print('Refreshing the VM list.')
elif action in (':run', ':new'):
if len(arguments) > 1:
raise ValueError('Use :run followed by one quoted ISO path or URL, or follow the prompts.')
code, name = _guided_run(runtime, manifest, arguments[0] if arguments else None)
if code == 0:
_remember(runtime, name)
last = name
if manifest is not None:
return 0
else:
print(f'{name}: launch returned {code}; enter its name to retry.')
elif action == ':download':
if len(arguments) > 1:
raise ValueError('Use :download followed by one quoted direct URL, or follow the prompts.')
url = _source_prompt('Direct ISO HTTP(S) URL (blank cancels)> ', arguments[0] if arguments else None)
sha256 = input('SHA-256 (optional)> ').strip() or None
_preview(['hydra', 'download', _redact_url(url)] + (['--sha256', sha256] if sha256 else []))
print('Downloading ISO; Ctrl+C cancels. Use a direct image URL, not a download page.')
print('ISO saved: ' + _safe(download_iso(url, sha256=sha256, progress=_progress())))
elif action in (':shutdown', ':detach'):
if len(arguments) != 1:
raise ValueError(f'Use {action} NAME.')
name = validate_name(arguments[0])
runtime.profile(name)
_preview(['hydra', 'shutdown', name] if action == ':shutdown' else ['hydra', 'media', name, '--detach'])
if action == ':shutdown':
runtime.shutdown(name)
print(f'{name}: guest shutdown requested; the guest controls completion.')
else:
runtime.media(name, None)
print(f'{name}: installation ISO detached.')
elif action == ':help':
print('Open explicitly starts a stopped VM or reconnects a running VM. Unknown identities need inspection.')
print('Ctrl+Alt+Shift+Left/Right switches VMs; Escape opens this menu; R releases input; U opens USB.')
print('Use :run for a new VM from a local ISO or direct URL. Blank source/name cancels.')
print('Use :shell for hydra status NAME, editing profiles, and advanced CLI options.')
else:
print('Choose a VM number/name, or a colon command. Use :help for details.')
except (EOFError, KeyboardInterrupt):
print('\nAction cancelled; menu remains available.')
except (RuntimeError, OSError, ValueError, subprocess.SubprocessError) as exc:
print('Error: ' + _safe(exc))
+97 -38
View File
@@ -15,8 +15,8 @@ from pathlib import Path
from urllib.parse import quote
from xml.etree import ElementTree as ET
from .profiles import validate_name
from .runtime import Runtime, VMPaths, _private_dir
from .profiles import load_profile, validate_name
from .runtime import Runtime, RuntimeError, VMPaths, _private_dir
class SessionError(ValueError):
@@ -24,7 +24,7 @@ class SessionError(ValueError):
def viewer_title(name: str) -> str:
return 'HydraOS: ' + validate_name(name)
return 'Hydra OS: ' + validate_name(name)
def build_viewer_argv(paths: VMPaths, viewer: str, *, windowed=False) -> list[str]:
@@ -101,7 +101,7 @@ def _viewer() -> str:
viewer = _tool('HYDRA_VIEWER', 'remote-viewer')
result = subprocess.run([viewer, '--help'], capture_output=True, text=True, timeout=10)
if result.returncode or '--hide-toolbar' not in result.stdout:
raise SessionError('Viewer lacks HydraOS toolbar/USB adaptation; use dev/tools.nix')
raise SessionError('Viewer lacks Hydra OS toolbar/USB adaptation; use dev/tools.nix')
return viewer
@@ -120,7 +120,7 @@ def _viewer_environment(directory: Path) -> dict[str, str]:
settings.mkdir(parents=True, mode=0o700)
(settings / 'settings').write_text('[virt-viewer]\nshare-clipboard=false\n', encoding='utf-8')
env = dict(os.environ, XDG_CONFIG_HOME=str(config), GSETTINGS_BACKEND='memory')
# A dedicated backend prevents accidental XWayland input behavior in HydraOS.
# A dedicated backend prevents accidental XWayland input behavior in Hydra OS.
if env.get('WAYLAND_DISPLAY'):
env['GDK_BACKEND'] = 'wayland'
return env
@@ -162,7 +162,7 @@ def _module_argv(command: str, manifest: Path) -> list[str]:
def _recovery_argv(manifest: Path, terminal: str) -> list[str]:
return [terminal, '--title=HydraOS-recovery'] + _module_argv('recover', manifest)
return [terminal, '--title=Hydra OS recovery'] + _module_argv('recover', manifest)
def launch_session(runtime: Runtime, *, nested=False) -> int:
@@ -173,11 +173,25 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
version = subprocess.run([labwc, '--version'], capture_output=True, text=True, timeout=10)
match = re.search(r'(\d+)\.(\d+)\.(\d+)', version.stdout)
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('Hydra OS requires labwc >= 0.20.0 for reserved host input bindings')
_private_dir(runtime.runtime)
selected = None
while True:
profiles = runtime.profiles()
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
profiles = []
for path in sorted(runtime.config.glob('*.toml')):
try:
profiles.append(load_profile(path))
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: {path.name}: {exc}', file=sys.stderr)
initial = selected if selected in [p.name for p in profiles] else None
if initial is None:
for profile in profiles:
try:
if runtime.status(profile.name)['status'] not in ('stopped', 'unknown'):
initial = profile.name
break
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: {profile.name}: {exc}', file=sys.stderr)
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
directory = Path(tmp)
config = directory / 'labwc'
@@ -192,6 +206,7 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
(config / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
(config / 'environment').touch()
env['PYTHONUNBUFFERED'] = '1'
env['HYDRA_SESSION_MANIFEST'] = str(manifest)
compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
refresh = False
try:
@@ -210,12 +225,13 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
compositor.wait(timeout=10)
if not refresh:
return compositor.returncode
selected = _refresh_selection(directory / 'refresh.request')
# 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 _manifest_data(manifest: Path) -> dict:
data = json.loads(manifest.read_text(encoding='utf-8'))
if (not isinstance(data, dict) or
any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute()
@@ -224,38 +240,79 @@ def session_helper(command: str, manifest: Path) -> int:
raise SessionError('Invalid session manifest')
for name in data['names']:
validate_name(name)
return data
def request_refresh(manifest: Path, *, selected: str | None = None) -> int:
"""Publish a complete request without controlling any guest process."""
manifest = Path(manifest)
data = _manifest_data(manifest)
if selected is not None:
validate_name(selected)
# Profiles can be created after the compositor's original snapshot.
Runtime(Path(data['config']), Path(data['state']), Path(data['runtime'])).profile(selected)
payload = '' if selected is None else json.dumps({'selected': selected})
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', dir=manifest.parent,
prefix='.refresh-', delete=False) as stream:
temporary = Path(stream.name)
try:
stream.write(payload)
stream.flush()
os.fsync(stream.fileno())
os.replace(temporary, manifest.parent / 'refresh.request')
finally:
temporary.unlink(missing_ok=True)
print('Refreshing workspaces and viewers; VMs keep running.')
return 0
def _refresh_selection(request: Path) -> str | None:
try:
payload = request.read_text(encoding='utf-8')
if not payload:
return None
data = json.loads(payload)
if not isinstance(data, dict) or set(data) != {'selected'}:
raise SessionError('Invalid refresh request')
return validate_name(data['selected'])
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: refresh selection ignored: {exc}', file=sys.stderr)
return None
def _emergency_recovery(manifest: Path) -> int:
print('Hydra OS emergency recovery. :shell, :refresh or :quit; VMs keep running.')
while True:
try:
choice = input('hydra recovery> ').strip()
if choice == ':quit':
return 0
if choice == ':shell':
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
elif choice == ':refresh':
return request_refresh(manifest)
except (EOFError, KeyboardInterrupt):
return 0
except (OSError, ValueError, subprocess.SubprocessError) as exc:
print(f'hydra: {exc}')
def session_helper(command: str, manifest: Path) -> int:
data = _manifest_data(manifest)
if command not in ('clients', 'recover', 'refresh') and (
not command.startswith('reconnect:') or command.split(':', 1)[1] not in data['names']):
raise SessionError('Invalid session helper operation')
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
return request_refresh(manifest)
if command == 'recover':
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'
'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:
try:
print(json.dumps([runtime.status(name) for name in data['names']], indent=2))
choice = input('hydra recovery> ').strip()
except (EOFError, KeyboardInterrupt):
return 0
if choice == ':quit':
return 0
if choice == ':refresh':
return session_helper('refresh', manifest)
if choice == ':shell':
subprocess.run([os.environ.get('SHELL', '/bin/sh')])
elif choice in data['names']:
subprocess.Popen(_module_argv('reconnect:' + choice, manifest), start_new_session=True)
else:
print('Enter a configured VM name, :shell, :refresh, or :quit.')
try:
from .menu import run_menu
return run_menu(runtime, manifest=manifest)
except Exception as exc:
# Recovery is the last local escape hatch, including menu defects.
print(f'hydra: menu unavailable: {exc}')
return _emergency_recovery(manifest)
if command.startswith('reconnect:'):
name = command.split(':', 1)[1]
# One viewer per VM inside this session; independent host view commands
@@ -275,9 +332,11 @@ def session_helper(command: str, manifest: Path) -> int:
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
for name in data['names']:
try:
if runtime.status(name)['status'] == 'running':
# Verified paused/prelaunch guests also have a live SPICE server;
# reconnecting must not imply a guest start or resume.
if runtime.status(name)['status'] not in ('stopped', 'unknown'):
processes.append(subprocess.Popen(_module_argv('reconnect:' + name, manifest)))
except (OSError, ValueError) as exc:
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: {name}: {exc}', file=sys.stderr)
if not processes:
open_recovery()
+168
View File
@@ -0,0 +1,168 @@
import contextlib
import importlib
import importlib.util
import io
import json
import os
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
from hydra.profiles import Profile
from hydra.runtime import Runtime, RuntimeError
class MenuTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='hm-')
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.manager = Runtime(self.root / 'config', self.root / 'state', self.root / 'run')
def menu(self):
self.assertIsNotNone(importlib.util.find_spec('hydra.menu'), 'Terminal menu is missing')
return importlib.import_module('hydra.menu')
def run_choices(self, choices, **kwargs):
output = io.StringIO()
with patch('builtins.input', side_effect=choices), contextlib.redirect_stdout(output):
code = self.menu().run_menu(self.manager, **kwargs)
self.assertEqual(code, 0)
return output.getvalue()
def test_display_is_readable_and_never_starts_a_vm(self):
self.manager.add(Profile('alpha', memory=512))
with patch.object(Runtime, 'start', side_effect=AssertionError('display started a VM')):
output = self.run_choices([':quit'])
for label in ('Hydra OS', 'alpha', 'stopped', '512 MiB', ':run', ':network', ':shell'):
self.assertIn(label, output)
self.assertNotIn('"status":', output)
self.assertFalse(self.manager.paths('alpha').disk.exists())
def test_number_selection_persists_and_enter_reuses_it(self):
self.manager.add(Profile('alpha'))
self.manager.add(Profile('beta'))
module = self.menu()
selected = []
def open_vm(runtime, name, **kwargs):
selected.append(name)
return 0
with patch.object(module, 'open_vm', side_effect=open_vm):
self.run_choices(['2', ':quit'])
self.run_choices(['', ':quit'])
self.assertEqual(selected, ['beta', 'beta'])
self.assertEqual(json.loads((self.manager.state / 'menu.json').read_text()), {'last_selected': 'beta'})
self.assertEqual((self.manager.state / 'menu.json').stat().st_mode & 0o777, 0o600)
def test_names_s_and_q_remain_vm_selections(self):
for name in ('s', 'q'):
self.manager.add(Profile(name))
selected = []
with patch.object(self.menu(), 'open_vm', side_effect=lambda runtime, name, **kw: selected.append(name) or 0):
self.run_choices(['s', 'q', ':quit'])
self.assertEqual(selected, ['s', 'q'])
def test_invalid_profile_and_unknown_identity_leave_other_vms_accessible(self):
self.manager.add(Profile('alpha'))
self.manager.add(Profile('beta'))
(self.manager.config / 'bad.toml').write_text('memory = "invalid"')
self.manager.paths('alpha').spice.parent.mkdir(parents=True)
self.manager.paths('alpha').spice.touch()
module = self.menu()
with patch.object(module, 'open_vm', side_effect=RuntimeError('alpha: unknown identity; inspect status')):
output = self.run_choices(['alpha', ':help', ':quit'])
self.assertIn('bad.toml', output)
self.assertIn('unknown', output)
self.assertIn('beta', output)
self.assertFalse((self.manager.state / 'menu.json').exists())
def test_empty_or_removed_selection_does_not_launch_and_shell_can_repair_profiles(self):
module = self.menu()
def repair(argv):
self.manager.add(Profile('fresh'))
return type('Result', (), {'returncode': 0})()
with patch.object(module.subprocess, 'run', side_effect=repair), patch.object(module, 'open_vm', return_value=0) as launch:
output = self.run_choices(['', ':shell', '', ':quit'])
self.assertIn('No VM profiles', output)
self.assertEqual([call.args[1] for call in launch.call_args_list], ['fresh'])
def test_missing_network_tool_and_cancelled_action_return_to_menu(self):
module = self.menu()
with patch.object(module.shutil, 'which', return_value=None):
output = self.run_choices([':network', ':run', '', ':quit'])
self.assertIn('nmtui', output)
self.assertIn('cancel', output.lower())
def test_refresh_and_successful_session_selection_return_to_compositor(self):
self.manager.add(Profile('beta'))
manifest = self.root / 'manifest.json'
module = self.menu()
with patch.object(module, 'open_vm', return_value=0) as launch:
self.run_choices([''], manifest=manifest)
self.assertEqual(launch.call_args.args[1], 'beta')
self.assertEqual(launch.call_args.kwargs['manifest'], manifest)
with patch.object(module, 'request_refresh', return_value=0) as refresh:
self.run_choices([':refresh'], manifest=manifest)
self.assertEqual(refresh.call_args.args, (manifest,))
def test_orderly_shutdown_and_detach_are_explicit_and_do_not_force_stop(self):
iso = self.root / 'install.iso'; iso.touch()
self.manager.add(Profile('alpha', iso))
module = self.menu()
with patch.object(Runtime, 'stop', side_effect=AssertionError('must not force stop')), patch.object(Runtime, 'shutdown', return_value={'status': 'shutdown-requested'}) as shutdown:
self.run_choices([':shutdown alpha', ':detach alpha', ':quit'])
self.assertEqual(shutdown.call_args.args, ('alpha',))
self.assertIsNone(self.manager.profile('alpha').iso)
def test_download_renders_safe_preview_and_transfer_progress(self):
module = self.menu()
iso = self.root / 'saved.iso'
def transfer(url, *, sha256=None, progress=None):
self.assertEqual(url, 'https://example.test/live.iso?token=private-token')
self.assertIsNotNone(progress, 'Download menu needs progress feedback')
progress(1048576, 2097152)
iso.write_bytes(b'complete fixture')
progress(2097152, 2097152)
return iso
with patch.object(module, 'download_iso', side_effect=transfer):
output = self.run_choices([':download https://example.test/live.iso?token=private-token', '', ':quit'])
self.assertEqual(iso.read_bytes(), b'complete fixture')
self.assertIn('2.0 / 2.0 MiB', output)
self.assertIn(str(iso), output)
self.assertNotIn('private-token', output)
def test_guided_run_applies_allocations_and_returns_after_selected_handoff(self):
module = self.menu()
def launch(runtime, source, **kwargs):
self.assertEqual(source, '/tmp/example.iso')
self.assertEqual(kwargs['disk_size'], '8G')
self.assertEqual(kwargs.get('defaults'), Path('/etc/hydra/defaults.toml'))
runtime.add(Profile(kwargs['name'], memory=kwargs['memory'], cpus=kwargs['cpus']))
return 0
with patch.object(module, 'run_source', side_effect=launch):
self.run_choices([':run /tmp/example.iso', 'new-vm', '512', '1', '8G', ''], manifest=self.root / 'manifest.json')
self.assertEqual(self.manager.profile('new-vm').memory, 512)
self.assertEqual(self.manager.profile('new-vm').cpus, 1)
self.assertEqual(json.loads((self.manager.state / 'menu.json').read_text()), {'last_selected': 'new-vm'})
def test_duplicate_guided_run_does_not_download_or_replace_profile(self):
self.manager.add(Profile('alpha', memory=512))
before = (self.manager.config / 'alpha.toml').read_bytes()
with patch.object(self.menu(), 'run_source', side_effect=AssertionError('existing name must not launch')):
output = self.run_choices([':run https://example.test/live.iso', 'alpha', ':quit'])
self.assertIn('hydra open alpha', output)
self.assertEqual((self.manager.config / 'alpha.toml').read_bytes(), before)
def test_corrupt_saved_selection_and_removed_vm_do_not_block_menu(self):
self.manager.state.mkdir()
(self.manager.state / 'menu.json').write_text('null')
self.run_choices(['', ':quit'])
(self.manager.state / 'menu.json').write_text('{"last_selected": "removed"}')
with patch.object(self.menu(), 'open_vm', side_effect=AssertionError('removed VM must not open')):
self.run_choices(['', ':quit'])
if __name__ == '__main__':
unittest.main()
+136 -18
View File
@@ -4,6 +4,7 @@ import io
import json
import os
import shlex
import shutil
import socket
import subprocess
import tempfile
@@ -13,7 +14,7 @@ from unittest.mock import patch
from xml.etree import ElementTree as ET
from hydra.profiles import Profile
from hydra.runtime import Runtime
from hydra.runtime import Runtime, RuntimeError as VMError
from hydra import session
from hydra.cli import main, parser
@@ -34,7 +35,7 @@ class SessionTests(unittest.TestCase):
self.assertEqual([n.text for n in tree.findall('./desktops/names/name')], ['alpha', 'beta'])
self.assertEqual(tree.findtext('./desktops/popupTime'), '0')
rules = tree.findall('./windowRules/windowRule')
self.assertEqual([r.get('title') for r in rules], ['HydraOS: alpha', 'HydraOS: beta'])
self.assertEqual([r.get('title') for r in rules], ['Hydra OS: alpha', 'Hydra OS: beta'])
for index, rule in enumerate(rules, 1):
self.assertEqual(rule.get('serverDecoration'), 'no')
self.assertEqual(rule.find("./action[@name='SendToDesktop']").get('to'), str(index))
@@ -214,7 +215,7 @@ sys.exit(0)
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'])
['Hydra OS: first', 'Hydra OS: second'])
self.assertFalse(manager.paths('first').disk.exists())
def test_no_running_vms_opens_recovery_without_start_or_stop(self):
@@ -226,7 +227,7 @@ sys.exit(0)
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state), 'runtime': str(self.manager.runtime), 'names': ['alpha', 'beta'], 'terminal': str(terminal), 'directory': str(self.root)}))
with patch.dict(os.environ, {'CAPTURE': str(capture)}), patch.object(Runtime, 'start', side_effect=AssertionError('must not start')), patch.object(Runtime, 'stop', side_effect=AssertionError('must not stop')):
self.assertEqual(session.session_helper('clients', manifest), 0)
self.assertEqual(json.loads(capture.read_text())[0], '--title=HydraOS-recovery')
self.assertEqual(json.loads(capture.read_text())[0], '--title=Hydra OS recovery')
self.assertEqual(self.manager.status('alpha')['status'], 'stopped')
self.assertFalse(self.manager.paths('alpha').disk.exists())
@@ -265,27 +266,22 @@ else:
return manifest, {'WAYLAND_DISPLAY': 'unused', 'HYDRA_VIEWER': str(viewer), 'RECOVERY_CAPTURE': str(self.root), 'SHELL': str(shell)}
def _check_recovery_vm_name(self, name):
import importlib.util
self.assertIsNotNone(importlib.util.find_spec('hydra.menu'), 'Terminal menu is missing')
from hydra import menu
manifest, env = self._recovery_fixture()
processes = []
original_popen = subprocess.Popen
def launch(*args, **kwargs):
process = original_popen(*args, **kwargs)
processes.append(process)
return process
with patch.dict(os.environ, env), patch('builtins.input', side_effect=[name, ':quit', EOFError()]), patch.object(subprocess, 'Popen', launch), contextlib.redirect_stdout(io.StringIO()):
with patch.dict(os.environ, env), patch('builtins.input', side_effect=[name, ':quit']), \
patch.object(menu, 'open_vm', return_value=0) as launch, contextlib.redirect_stdout(io.StringIO()):
self.assertEqual(session.session_helper('recover', manifest), 0)
for process in processes:
self.assertEqual(process.wait(timeout=5), 0)
connected = self.root / (name + '.connected')
self.assertTrue(connected.exists(), f'VM {name} was not reconnected')
self.assertEqual(connected.read_text(), name)
self.assertEqual(launch.call_args.args[1], name)
self.assertEqual(launch.call_args.kwargs['manifest'], manifest)
self.assertFalse((self.root / 'shell.opened').exists())
self.assertFalse(self.manager.paths(name).disk.exists())
def test_recovery_reconnects_vm_named_s(self):
def test_recovery_selects_vm_named_s(self):
self._check_recovery_vm_name('s')
def test_recovery_reconnects_vm_named_q(self):
def test_recovery_selects_vm_named_q(self):
self._check_recovery_vm_name('q')
def test_recovery_colon_commands_open_shell_and_close_terminal(self):
@@ -297,5 +293,127 @@ else:
self.assertFalse((self.root / 'q.connected').exists())
def test_refresh_payload_selects_fresh_profile_and_legacy_empty_request_is_accepted(self):
manifest = self.root / 'session.json'
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state),
'runtime': str(self.manager.runtime), 'names': ['alpha'], 'terminal': '/bin/sh'}))
self.assertTrue(hasattr(session, 'request_refresh'), 'Selected refresh API is missing')
self.assertEqual(session.request_refresh(manifest, selected='beta'), 0)
request = self.root / 'refresh.request'
self.assertEqual(json.loads(request.read_text()), {'selected': 'beta'})
self.assertEqual(request.stat().st_mode & 0o777, 0o600)
request.unlink()
self.assertEqual(session.request_refresh(manifest), 0)
self.assertEqual(request.read_bytes(), b'')
def test_refresh_rejects_invalid_or_removed_selection_without_publishing(self):
manifest = self.root / 'session.json'
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state),
'runtime': str(self.manager.runtime), 'names': ['alpha'], 'terminal': '/bin/sh'}))
self.assertTrue(hasattr(session, 'request_refresh'), 'Selected refresh API is missing')
for name in ('missing', '../alpha', 'bad*'):
with self.assertRaises((ValueError, OSError)):
session.request_refresh(manifest, selected=name)
self.assertFalse((self.root / 'refresh.request').exists())
def test_selected_second_workspace_survives_compositor_refresh_without_starting_guests(self):
capture = self.root / 'selected-capture.json'
compositor = self.root / 'selected-labwc'
compositor.write_text('#!' + os.sys.executable + '\n' + r'''import json, os, 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'
capture = Path(os.environ['CAPTURE'])
seen = json.loads(capture.read_text()) if capture.exists() else []
seen.append({'xml': (config / 'rc.xml').read_text(), 'manifest_env': os.environ.get('HYDRA_SESSION_MANIFEST'),
'manifest_path': str(manifest)})
capture.write_text(json.dumps(seen))
if len(seen) == 1:
(manifest.parent / 'refresh.request').write_text('{"selected": "beta"}')
''')
compositor.chmod(0o700)
with patch.dict(os.environ, {'HYDRA_LABWC': str(compositor), 'HYDRA_TERMINAL': str(compositor),
'HYDRA_BRIGHTNESS': str(compositor), 'CAPTURE': str(capture)}), \
patch.object(Runtime, 'start', side_effect=AssertionError('session must never start guests')):
self.assertEqual(session.launch_session(self.manager), 0)
seen = json.loads(capture.read_text())
self.assertEqual(len(seen), 2)
self.assertEqual(ET.fromstring(seen[1]['xml']).findtext('./desktops/initial'), '2')
self.assertTrue(all(s['manifest_env'] == s['manifest_path'] for s in seen))
self.assertFalse(self.manager.paths('alpha').disk.exists())
self.assertFalse(self.manager.paths('beta').disk.exists())
def test_recovery_failure_leaves_emergency_shell_accessible(self):
manifest, env = self._recovery_fixture()
import importlib.util
self.assertIsNotNone(importlib.util.find_spec('hydra.menu'), 'Terminal menu is missing')
from hydra import menu
with patch.dict(os.environ, env), patch.object(menu, 'run_menu', side_effect=TypeError('broken menu')), \
patch('builtins.input', side_effect=[':shell', ':quit']), contextlib.redirect_stdout(io.StringIO()) as output:
try:
code = session.session_helper('recover', manifest)
except TypeError as exc:
self.fail(f'Menu failure escaped emergency recovery: {exc}')
self.assertEqual(code, 0)
self.assertIn('emergency', output.getvalue().lower())
self.assertTrue((self.root / 'shell.opened').exists())
def test_runtime_status_error_opens_recovery_without_starting_guests(self):
manifest = self.root / 'session.json'
manifest.write_text(json.dumps({'config': str(self.manager.config), 'state': str(self.manager.state),
'runtime': str(self.manager.runtime), 'names': ['alpha'], 'terminal': shutil.which('true')}))
with patch.object(Runtime, 'status', side_effect=VMError('private runtime permission problem')), \
patch.object(Runtime, 'start', side_effect=AssertionError('must not start')), \
contextlib.redirect_stderr(io.StringIO()) as error:
try:
code = session.session_helper('clients', manifest)
except VMError as exc:
self.fail(f'Status failure escaped session recovery: {exc}')
self.assertEqual(code, 0)
self.assertIn('private runtime permission problem', error.getvalue())
def test_invalid_profile_is_excluded_from_compositor_but_valid_profiles_remain(self):
(self.manager.config / 'bad.toml').write_text('memory = "broken"')
capture = self.root / 'invalid-capture.json'
compositor = self.root / 'invalid-labwc'
compositor.write_text('#!' + os.sys.executable + '\n' + r'''import json, os, sys
from pathlib import Path
if '--version' in sys.argv:
print('labwc 0.20.2')
else:
config = Path(sys.argv[sys.argv.index('-C') + 1])
Path(os.environ['CAPTURE']).write_text((config.parent / 'session.json').read_text())
''')
compositor.chmod(0o700)
with patch.dict(os.environ, {'HYDRA_LABWC': str(compositor), 'HYDRA_TERMINAL': str(compositor),
'HYDRA_BRIGHTNESS': str(compositor), 'CAPTURE': str(capture)}), \
contextlib.redirect_stderr(io.StringIO()) as error:
self.assertEqual(session.launch_session(self.manager), 0)
self.assertEqual(json.loads(capture.read_text())['names'], ['alpha', 'beta'])
self.assertIn('bad.toml', error.getvalue())
def test_paused_guests_reconnect_viewers_without_start_or_resume(self):
manifest, env = self._recovery_fixture()
terminal = self.root / 'exit-terminal'
terminal.write_text('#!' + os.sys.executable + '\n')
terminal.chmod(0o700)
data = json.loads(manifest.read_text())
data['terminal'] = str(terminal)
manifest.write_text(json.dumps(data))
with patch.dict(os.environ, env), patch.object(Runtime, 'status', return_value={'status': 'paused'}), \
patch.object(Runtime, 'start', side_effect=AssertionError('must not start/resume')), \
patch.object(Runtime, 'stop', side_effect=AssertionError('must not stop')), \
contextlib.redirect_stderr(io.StringIO()):
self.assertEqual(session.session_helper('clients', manifest), 0)
for name in ('s', 'q'):
self.assertTrue((self.root / (name + '.connected')).exists(), f'Paused {name} was not reconnected')
self.assertFalse(self.manager.paths(name).disk.exists())
if __name__ == '__main__':
unittest.main()