Add terminal VM menu and focused session handoff
This commit is contained in:
@@ -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 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()
|
||||
|
||||
Reference in New Issue
Block a user