Files
Hydra-OS/hydra/menu.py
T

247 lines
11 KiB
Python

"""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))