From c6099103b2259f79526a5690b6ef50b6b4f6cfc1 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 13 Oct 2026 15:18:03 +0300 Subject: [PATCH] Add terminal VM menu and focused session handoff --- dev/session-refresh-smoke.py | 37 ++++-- hydra/menu.py | 246 +++++++++++++++++++++++++++++++++++ hydra/session.py | 135 +++++++++++++------ tests/test_menu.py | 168 ++++++++++++++++++++++++ tests/test_session.py | 154 +++++++++++++++++++--- 5 files changed, 675 insertions(+), 65 deletions(-) create mode 100644 hydra/menu.py create mode 100644 tests/test_menu.py diff --git a/dev/session-refresh-smoke.py b/dev/session-refresh-smoke.py index d87e514..d563aa4 100644 --- a/dev/session-refresh-smoke.py +++ b/dev/session-refresh-smoke.py @@ -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()), diff --git a/hydra/menu.py b/hydra/menu.py new file mode 100644 index 0000000..1fc4ff3 --- /dev/null +++ b/hydra/menu.py @@ -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 '' + 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)) diff --git a/hydra/session.py b/hydra/session.py index 847f69b..384f0f3 100644 --- a/hydra/session.py +++ b/hydra/session.py @@ -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() diff --git a/tests/test_menu.py b/tests/test_menu.py new file mode 100644 index 0000000..4e3b335 --- /dev/null +++ b/tests/test_menu.py @@ -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() diff --git a/tests/test_session.py b/tests/test_session.py index 91c42bd..b2e7df2 100644 --- a/tests/test_session.py +++ b/tests/test_session.py @@ -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()