150 lines
7.4 KiB
Python
150 lines
7.4 KiB
Python
"""Portable source-checkout command line interface."""
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
from .profiles import Profile, load_defaults
|
|
from .runtime import Runtime, RuntimeError
|
|
from .session import launch_session, session_helper, view
|
|
|
|
|
|
def parser() -> argparse.ArgumentParser:
|
|
cli = argparse.ArgumentParser(prog='hydra', description='Hydra OS independent VM manager')
|
|
cli.add_argument('--config', type=Path, default=Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'hydra')
|
|
cli.add_argument('--state', type=Path, default=Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'hydra')
|
|
cli.add_argument('--runtime', type=Path, default=Path(os.environ.get('XDG_RUNTIME_DIR', f'/tmp/hydra-{os.getuid()}')) / 'hydra')
|
|
cli.add_argument('--defaults', type=Path, default=Path('/etc/hydra/defaults.toml'),
|
|
help='Distribution preferences for newly created profiles')
|
|
commands = cli.add_subparsers(dest='command')
|
|
commands.add_parser('menu', help='Open the terminal VM menu')
|
|
download = commands.add_parser('download', help='Download and validate a direct ISO URL')
|
|
download.add_argument('url')
|
|
download.add_argument('--sha256')
|
|
run = commands.add_parser('run', help='Create, start and display a VM from a local ISO or HTTP(S) URL')
|
|
run.add_argument('source')
|
|
run.add_argument('--name', required=True)
|
|
run.add_argument('--memory', '--ram', type=int, default=4096, help='VM RAM in MiB')
|
|
run.add_argument('--cpus', type=int, default=2)
|
|
run.add_argument('--disk-size', default='40G')
|
|
run.add_argument('--sha256')
|
|
run.add_argument('--no-view', action='store_true')
|
|
run.add_argument('--windowed', action='store_true')
|
|
opener = commands.add_parser('open', help='Start a stopped VM or reconnect a running VM')
|
|
opener.add_argument('name')
|
|
opener.add_argument('--no-view', action='store_true')
|
|
opener.add_argument('--windowed', action='store_true')
|
|
add = commands.add_parser('add', help='Create a VM profile without starting it')
|
|
add.add_argument('name')
|
|
add.add_argument('--iso', type=Path)
|
|
add.add_argument('--memory', type=int, default=4096)
|
|
add.add_argument('--cpus', type=int, default=2)
|
|
add.add_argument('--disk-size', default='40G')
|
|
add.add_argument('--graphics', choices=('auto', 'software'), default='auto')
|
|
add.add_argument('--network', choices=('user', 'bridge'))
|
|
add.add_argument('--bridge')
|
|
add.add_argument('--width', type=int)
|
|
add.add_argument('--height', type=int)
|
|
add.add_argument('--refresh-millihz', type=int)
|
|
media = commands.add_parser('media', help='Attach or detach installation media while stopped')
|
|
media.add_argument('name')
|
|
selection = media.add_mutually_exclusive_group(required=True)
|
|
selection.add_argument('--iso', type=Path)
|
|
selection.add_argument('--detach', action='store_true')
|
|
commands.add_parser('list', help='List configured VMs')
|
|
status = commands.add_parser('status', help='Query VM state through private QMP')
|
|
status.add_argument('name', nargs='?')
|
|
for command in ('start', 'shutdown', 'stop'):
|
|
cmd = commands.add_parser(command)
|
|
cmd.add_argument('name')
|
|
if command == 'stop':
|
|
cmd.add_argument('--force', action='store_true', required=True)
|
|
viewer = commands.add_parser('view', help='Connect a viewer without changing VM power')
|
|
viewer.add_argument('name')
|
|
viewer.add_argument('--windowed', action='store_true')
|
|
session = commands.add_parser('session', help='Start the minimal Hydra OS labwc session')
|
|
session.add_argument('--nested', action='store_true')
|
|
helper = commands.add_parser('_session-helper', help=argparse.SUPPRESS)
|
|
helper.add_argument('operation')
|
|
helper.add_argument('manifest', type=Path)
|
|
return cli
|
|
|
|
|
|
def _download_progress():
|
|
last = None
|
|
interactive = sys.stderr.isatty()
|
|
def report(received, total):
|
|
nonlocal last
|
|
step = received * 100 // total if total else received // (64 * 1024 * 1024)
|
|
if last == step:
|
|
return
|
|
last = step
|
|
if not interactive and received and (total is None or received != total):
|
|
return
|
|
suffix = f' / {total // (1024 * 1024)} MiB ({step}%)' if total else ' MiB'
|
|
print(f'Download: {received // (1024 * 1024)}{suffix}', file=sys.stderr)
|
|
return report
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
cli = parser()
|
|
args = cli.parse_args(argv)
|
|
runtime = Runtime(args.config, args.state, args.runtime)
|
|
try:
|
|
if args.command is None and not sys.stdin.isatty():
|
|
cli.print_help()
|
|
return 0
|
|
if args.command in (None, 'menu'):
|
|
from .launch import session_manifest
|
|
from .menu import run_menu
|
|
return run_menu(runtime, manifest=session_manifest(runtime), defaults=args.defaults)
|
|
if args.command == 'download':
|
|
from .media import download_iso
|
|
print(download_iso(args.url, sha256=args.sha256, progress=_download_progress()))
|
|
return 0
|
|
if args.command == 'run':
|
|
from .launch import run_source
|
|
return run_source(runtime, args.source, name=args.name, memory=args.memory,
|
|
cpus=args.cpus, disk_size=args.disk_size, sha256=args.sha256,
|
|
no_view=args.no_view, windowed=args.windowed, defaults=args.defaults,
|
|
progress=_download_progress())
|
|
if args.command == 'open':
|
|
from .launch import open_vm
|
|
return open_vm(runtime, args.name, no_view=args.no_view, windowed=args.windowed)
|
|
if args.command == 'view':
|
|
return view(runtime, args.name, windowed=args.windowed)
|
|
elif args.command == 'session':
|
|
return launch_session(runtime, nested=args.nested)
|
|
elif args.command == '_session-helper':
|
|
return session_helper(args.operation, args.manifest)
|
|
elif args.command == 'add':
|
|
preferences = load_defaults(args.defaults)
|
|
preferences.update({key: getattr(args, key) for key in
|
|
('network', 'bridge', 'width', 'height', 'refresh_millihz')
|
|
if getattr(args, key) is not None})
|
|
profile = Profile(args.name, args.iso, args.memory, args.cpus, args.disk_size,
|
|
args.graphics, **preferences)
|
|
runtime.add(profile)
|
|
result = {'name': profile.name, 'status': 'added'}
|
|
elif args.command == 'media':
|
|
result = runtime.media(args.name, None if args.detach else args.iso)
|
|
elif args.command == 'list':
|
|
result = [{'name': profile.name, 'iso': str(profile.iso) if profile.iso else None} for profile in runtime.profiles()]
|
|
elif args.command == 'status':
|
|
result = runtime.status(args.name) if args.name else [runtime.status(p.name) for p in runtime.profiles()]
|
|
elif args.command == 'stop':
|
|
result = runtime.stop(args.name, force=args.force)
|
|
else:
|
|
result = getattr(runtime, args.command)(args.name)
|
|
print(json.dumps(result, indent=2))
|
|
return 0
|
|
except KeyboardInterrupt:
|
|
print('hydra: Cancelled', file=sys.stderr)
|
|
return 130
|
|
except (RuntimeError, OSError, ValueError, subprocess.TimeoutExpired) as exc:
|
|
print(f'hydra: {exc}', file=sys.stderr)
|
|
return 1
|