Add terminal VM menu and focused session handoff
This commit is contained in:
@@ -130,7 +130,7 @@ identities = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
|||||||
assert first['pid'] != second['pid'], 'compositor was not refreshed'
|
assert first['pid'] != second['pid'], 'compositor was not refreshed'
|
||||||
logs = {name: root / (name + '.log') for name in ('alpha', 'beta')}
|
logs = {name: root / (name + '.log') for name in ('alpha', 'beta')}
|
||||||
for name, path in logs.items():
|
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')
|
name + ' product viewer did not connect')
|
||||||
viewers = {name: json.loads((root / (name + '-viewer.json')).read_text()) for name in logs}
|
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'
|
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']
|
assert '--spice-usbredir-redirect-on-connect=-1,-1,-1,-1,0' in data['argv']
|
||||||
alog, blog = logs['alpha'], logs['beta']
|
alog, blog = logs['alpha'], logs['beta']
|
||||||
display = next(p.name for p in xdg.glob('wayland-*') if not p.name.endswith('.lock'))
|
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)
|
keyboard_env = dict(env, WAYLAND_DISPLAY=display)
|
||||||
injector = subprocess.Popen([args.keyboard], env=keyboard_env, stdin=subprocess.PIPE, text=True)
|
injector = subprocess.Popen([args.keyboard], env=keyboard_env, stdin=subprocess.PIPE, text=True)
|
||||||
time.sleep(.2)
|
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):
|
def switch(code, source_log, target_log):
|
||||||
before_source, before_target = focus_events(source_log), focus_events(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,
|
wait_for(lambda: fresh_focus_transition(before_source, before_target,
|
||||||
focus_events(source_log), focus_events(target_log)), 'fresh reserved focus transition failed')
|
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)
|
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())
|
before = json.loads((root / 'before.json').read_text())
|
||||||
after = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
after = {name: {'pid': manager.paths(name).pid.read_text().strip(),
|
||||||
'uuid': manager.paths(name).uuid, 'counter': counter(manager, name)}
|
'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']
|
assert after[name]['counter'] != before[name]['counter']
|
||||||
# Only the PID recorded by our own wrapper, verified against its private
|
# Only the PID recorded by our own wrapper, verified against its private
|
||||||
# config path, may be signalled. Never inherit a host compositor PID.
|
# config path, may be signalled. Never inherit a host compositor PID.
|
||||||
command = Path('/proc') / str(second['pid']) / 'cmdline'
|
command = Path('/proc') / str(third['pid']) / 'cmdline'
|
||||||
assert second['config_directory'].encode() in command.read_bytes()
|
assert third['config_directory'].encode() in command.read_bytes()
|
||||||
os.kill(second['pid'], signal.SIGTERM)
|
os.kill(third['pid'], signal.SIGTERM)
|
||||||
assert launcher.wait(timeout=10) == 0, 'product session exit failed'
|
assert launcher.wait(timeout=10) == 0, 'product session exit failed'
|
||||||
for name in before:
|
for name in before:
|
||||||
assert manager.status(name)['status'] == 'running'
|
assert manager.status(name)['status'] == 'running'
|
||||||
assert manager.paths(name).pid.read_text().strip() == before[name]['pid']
|
assert manager.paths(name).pid.read_text().strip() == before[name]['pid']
|
||||||
result.update(initial_names=first['names'], refreshed_names=second['names'],
|
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,
|
viewers=viewers,
|
||||||
hidden_intervals=intervals, survived_session_exit=True,
|
hidden_intervals=intervals, survived_session_exit=True,
|
||||||
expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()),
|
expected_xwayland_diagnostics=classify_compositor_errors((root / 'session.log').read_text()),
|
||||||
|
|||||||
+246
@@ -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
@@ -15,8 +15,8 @@ from pathlib import Path
|
|||||||
from urllib.parse import quote
|
from urllib.parse import quote
|
||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from .profiles import validate_name
|
from .profiles import load_profile, validate_name
|
||||||
from .runtime import Runtime, VMPaths, _private_dir
|
from .runtime import Runtime, RuntimeError, VMPaths, _private_dir
|
||||||
|
|
||||||
|
|
||||||
class SessionError(ValueError):
|
class SessionError(ValueError):
|
||||||
@@ -24,7 +24,7 @@ class SessionError(ValueError):
|
|||||||
|
|
||||||
|
|
||||||
def viewer_title(name: str) -> str:
|
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]:
|
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')
|
viewer = _tool('HYDRA_VIEWER', 'remote-viewer')
|
||||||
result = subprocess.run([viewer, '--help'], capture_output=True, text=True, timeout=10)
|
result = subprocess.run([viewer, '--help'], capture_output=True, text=True, timeout=10)
|
||||||
if result.returncode or '--hide-toolbar' not in result.stdout:
|
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
|
return viewer
|
||||||
|
|
||||||
|
|
||||||
@@ -120,7 +120,7 @@ def _viewer_environment(directory: Path) -> dict[str, str]:
|
|||||||
settings.mkdir(parents=True, mode=0o700)
|
settings.mkdir(parents=True, mode=0o700)
|
||||||
(settings / 'settings').write_text('[virt-viewer]\nshare-clipboard=false\n', encoding='utf-8')
|
(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')
|
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'):
|
if env.get('WAYLAND_DISPLAY'):
|
||||||
env['GDK_BACKEND'] = 'wayland'
|
env['GDK_BACKEND'] = 'wayland'
|
||||||
return env
|
return env
|
||||||
@@ -162,7 +162,7 @@ def _module_argv(command: str, manifest: Path) -> list[str]:
|
|||||||
|
|
||||||
|
|
||||||
def _recovery_argv(manifest: Path, terminal: str) -> 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:
|
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)
|
version = subprocess.run([labwc, '--version'], capture_output=True, text=True, timeout=10)
|
||||||
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('Hydra OS requires labwc >= 0.20.0 for reserved host input bindings')
|
||||||
_private_dir(runtime.runtime)
|
_private_dir(runtime.runtime)
|
||||||
|
selected = None
|
||||||
while True:
|
while True:
|
||||||
profiles = runtime.profiles()
|
profiles = []
|
||||||
initial = next((p.name for p in profiles if runtime.status(p.name)['status'] == 'running'), None)
|
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:
|
with tempfile.TemporaryDirectory(prefix='session-', dir=runtime.runtime) as tmp:
|
||||||
directory = Path(tmp)
|
directory = Path(tmp)
|
||||||
config = directory / 'labwc'
|
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 / 'autostart').write_text('#!/bin/sh\n' + shlex.join(_module_argv('clients', manifest)) + ' &\n', encoding='utf-8')
|
||||||
(config / 'environment').touch()
|
(config / 'environment').touch()
|
||||||
env['PYTHONUNBUFFERED'] = '1'
|
env['PYTHONUNBUFFERED'] = '1'
|
||||||
|
env['HYDRA_SESSION_MANIFEST'] = str(manifest)
|
||||||
compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
|
compositor = subprocess.Popen([labwc, '-C', str(config)], env=env)
|
||||||
refresh = False
|
refresh = False
|
||||||
try:
|
try:
|
||||||
@@ -210,12 +225,13 @@ def launch_session(runtime: Runtime, *, nested=False) -> int:
|
|||||||
compositor.wait(timeout=10)
|
compositor.wait(timeout=10)
|
||||||
if not refresh:
|
if not refresh:
|
||||||
return compositor.returncode
|
return compositor.returncode
|
||||||
|
selected = _refresh_selection(directory / 'refresh.request')
|
||||||
# Only our compositor was stopped. Its viewers disconnect; detached
|
# Only our compositor was stopped. Its viewers disconnect; detached
|
||||||
# QEMU instances retain their identity and running workloads.
|
# 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'))
|
data = json.loads(manifest.read_text(encoding='utf-8'))
|
||||||
if (not isinstance(data, dict) or
|
if (not isinstance(data, dict) or
|
||||||
any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute()
|
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')
|
raise SessionError('Invalid session manifest')
|
||||||
for name in data['names']:
|
for name in data['names']:
|
||||||
validate_name(name)
|
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 (
|
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':
|
if command == 'refresh':
|
||||||
fd = os.open(manifest.parent / 'refresh.request', os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW, 0o600)
|
return request_refresh(manifest)
|
||||||
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'
|
try:
|
||||||
'Ctrl+Alt+Shift+Left/Right switches workspaces; Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+U opens USB.\n'
|
from .menu import run_menu
|
||||||
'Enter a VM name to reconnect, :shell for a host shell, :refresh to rebuild workspaces/viewers, or :quit.')
|
return run_menu(runtime, manifest=manifest)
|
||||||
if not data['names']:
|
except Exception as exc:
|
||||||
print('No VM profiles yet. Use :shell to run hydra add NAME --iso PATH and hydra start NAME.\n'
|
# Recovery is the last local escape hatch, including menu defects.
|
||||||
'After adding or removing profiles, leave the shell and enter :refresh; running VMs are preserved.')
|
print(f'hydra: menu unavailable: {exc}')
|
||||||
while True:
|
return _emergency_recovery(manifest)
|
||||||
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.')
|
|
||||||
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
|
||||||
@@ -275,9 +332,11 @@ def session_helper(command: str, manifest: Path) -> int:
|
|||||||
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
|
recovery = subprocess.Popen(_recovery_argv(manifest, data['terminal']))
|
||||||
for name in data['names']:
|
for name in data['names']:
|
||||||
try:
|
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)))
|
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)
|
print(f'hydra: {name}: {exc}', file=sys.stderr)
|
||||||
if not processes:
|
if not processes:
|
||||||
open_recovery()
|
open_recovery()
|
||||||
|
|||||||
@@ -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
@@ -4,6 +4,7 @@ import io
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import shlex
|
import shlex
|
||||||
|
import shutil
|
||||||
import socket
|
import socket
|
||||||
import subprocess
|
import subprocess
|
||||||
import tempfile
|
import tempfile
|
||||||
@@ -13,7 +14,7 @@ from unittest.mock import patch
|
|||||||
from xml.etree import ElementTree as ET
|
from xml.etree import ElementTree as ET
|
||||||
|
|
||||||
from hydra.profiles import Profile
|
from hydra.profiles import Profile
|
||||||
from hydra.runtime import Runtime
|
from hydra.runtime import Runtime, RuntimeError as VMError
|
||||||
from hydra import session
|
from hydra import session
|
||||||
from hydra.cli import main, parser
|
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([n.text for n in tree.findall('./desktops/names/name')], ['alpha', 'beta'])
|
||||||
self.assertEqual(tree.findtext('./desktops/popupTime'), '0')
|
self.assertEqual(tree.findtext('./desktops/popupTime'), '0')
|
||||||
rules = tree.findall('./windowRules/windowRule')
|
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):
|
for index, rule in enumerate(rules, 1):
|
||||||
self.assertEqual(rule.get('serverDecoration'), 'no')
|
self.assertEqual(rule.get('serverDecoration'), 'no')
|
||||||
self.assertEqual(rule.find("./action[@name='SendToDesktop']").get('to'), str(index))
|
self.assertEqual(rule.find("./action[@name='SendToDesktop']").get('to'), str(index))
|
||||||
@@ -214,7 +215,7 @@ sys.exit(0)
|
|||||||
tree = ET.fromstring(seen[1]['xml'])
|
tree = ET.fromstring(seen[1]['xml'])
|
||||||
self.assertEqual(tree.find('desktops').get('number'), '2')
|
self.assertEqual(tree.find('desktops').get('number'), '2')
|
||||||
self.assertEqual([rule.get('title') for rule in tree.findall('./windowRules/windowRule')],
|
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())
|
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):
|
||||||
@@ -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)}))
|
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')):
|
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(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.assertEqual(self.manager.status('alpha')['status'], 'stopped')
|
||||||
self.assertFalse(self.manager.paths('alpha').disk.exists())
|
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)}
|
return manifest, {'WAYLAND_DISPLAY': 'unused', 'HYDRA_VIEWER': str(viewer), 'RECOVERY_CAPTURE': str(self.root), 'SHELL': str(shell)}
|
||||||
|
|
||||||
def _check_recovery_vm_name(self, name):
|
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()
|
manifest, env = self._recovery_fixture()
|
||||||
processes = []
|
with patch.dict(os.environ, env), patch('builtins.input', side_effect=[name, ':quit']), \
|
||||||
original_popen = subprocess.Popen
|
patch.object(menu, 'open_vm', return_value=0) as launch, contextlib.redirect_stdout(io.StringIO()):
|
||||||
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()):
|
|
||||||
self.assertEqual(session.session_helper('recover', manifest), 0)
|
self.assertEqual(session.session_helper('recover', manifest), 0)
|
||||||
for process in processes:
|
self.assertEqual(launch.call_args.args[1], name)
|
||||||
self.assertEqual(process.wait(timeout=5), 0)
|
self.assertEqual(launch.call_args.kwargs['manifest'], manifest)
|
||||||
connected = self.root / (name + '.connected')
|
|
||||||
self.assertTrue(connected.exists(), f'VM {name} was not reconnected')
|
|
||||||
self.assertEqual(connected.read_text(), name)
|
|
||||||
self.assertFalse((self.root / 'shell.opened').exists())
|
self.assertFalse((self.root / 'shell.opened').exists())
|
||||||
self.assertFalse(self.manager.paths(name).disk.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')
|
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')
|
self._check_recovery_vm_name('q')
|
||||||
|
|
||||||
def test_recovery_colon_commands_open_shell_and_close_terminal(self):
|
def test_recovery_colon_commands_open_shell_and_close_terminal(self):
|
||||||
@@ -297,5 +293,127 @@ else:
|
|||||||
self.assertFalse((self.root / 'q.connected').exists())
|
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__':
|
if __name__ == '__main__':
|
||||||
unittest.main()
|
unittest.main()
|
||||||
|
|||||||
Reference in New Issue
Block a user