248 lines
11 KiB
Python
248 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; 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))
|