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