"""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, *, defaults: Path): 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 prefix = ['hydra'] if defaults != Path('/etc/hydra/defaults.toml'): prefix += ['--defaults', str(defaults)] _preview(prefix + ['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=defaults, 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, defaults: Path = Path('/etc/hydra/defaults.toml')) -> 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, defaults=defaults) 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; Ctrl+Alt+Shift+Escape opens this menu.') print('Ctrl+Alt+Shift+R releases input; Ctrl+Alt+Shift+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))