diff --git a/hydra/cli.py b/hydra/cli.py index 48d159b..1e593b8 100644 --- a/hydra/cli.py +++ b/hydra/cli.py @@ -13,13 +13,30 @@ from .session import launch_session, session_helper, view def parser() -> argparse.ArgumentParser: - cli = argparse.ArgumentParser(prog='hydra', description='Independent HydraOS VM manager') + 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', required=True) + 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) @@ -48,7 +65,7 @@ def parser() -> argparse.ArgumentParser: 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 HydraOS labwc session') + 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') @@ -56,10 +73,47 @@ def parser() -> argparse.ArgumentParser: 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: - args = parser().parse_args(argv) + 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': @@ -87,6 +141,9 @@ def main(argv: list[str] | None = None) -> int: 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 diff --git a/hydra/launch.py b/hydra/launch.py new file mode 100644 index 0000000..acff051 --- /dev/null +++ b/hydra/launch.py @@ -0,0 +1,123 @@ +"""Explicit start-if-stopped and new-ISO launch, preserving runtime authority.""" + +import json +import os +from pathlib import Path + +from . import media, session +from .profiles import Profile, load_defaults, validate_name +from .runtime import Runtime, RuntimeError + + +HOST_RESERVE_MIB = 768 + + +def available_memory_mib() -> int: + """Linux MemAvailable accounts for reclaimable host caches.""" + try: + for line in Path('/proc/meminfo').read_text().splitlines(): + if line.startswith('MemAvailable:'): + return int(line.split()[1]) // 1024 + except (OSError, ValueError, IndexError) as exc: + raise RuntimeError('Cannot estimate available RAM from /proc/meminfo') from exc + raise RuntimeError('Cannot estimate available RAM: MemAvailable is missing') + + +def _memory_preflight(memory: int) -> None: + available = available_memory_mib() + if memory + HOST_RESERVE_MIB > available: + raise RuntimeError(f'Not enough available RAM: VM needs {memory} MiB plus a {HOST_RESERVE_MIB} MiB host reserve; ' + f'{available} MiB available. Choose less --memory or stop another VM') + + +def session_manifest(runtime: Runtime, manifest: Path | None = None) -> Path | None: + """Use an inherited session only when it belongs to these runtime roots.""" + explicit = manifest is not None + inherited = os.environ.get('HYDRA_SESSION_MANIFEST') + if not explicit and not inherited: + return None + path = Path(manifest if explicit else inherited).expanduser().absolute() + try: + data = json.loads(path.read_text(encoding='utf-8')) + if (not isinstance(data, dict) or + any(not isinstance(data.get(key), str) or not Path(data[key]).is_absolute() + or Path(data[key]) != getattr(runtime, key) for key in ('config', 'state', 'runtime')) or + not isinstance(data.get('names'), list) or + any(not isinstance(name, str) for name in data['names']) or + not isinstance(data.get('terminal'), str) or not Path(data['terminal']).is_absolute() or + not isinstance(data.get('directory'), str) or Path(data['directory']) != path.parent): + raise ValueError('Invalid session manifest or different runtime roots') + for name in data['names']: + validate_name(name) + return path + except (OSError, ValueError) as exc: + if explicit: + raise session.SessionError('Invalid session manifest; start a fresh Hydra OS session') from exc + return None + + +def _view_preflight(runtime: Runtime, *, no_view: bool, manifest: Path | None) -> Path | None: + if no_view: + return None + selected = session_manifest(runtime, manifest) + session._display() + session._viewer() + return selected + + +def _show(runtime: Runtime, name: str, *, no_view: bool, windowed: bool, + manifest: Path | None) -> int: + if no_view: + return 0 + if manifest is not None: + return session.request_refresh(manifest, selected=name) + return session.view(runtime, name, windowed=windowed) + + +def open_vm(runtime: Runtime, name: str, *, windowed=False, no_view=False, + manifest: Path | None = None) -> int: + profile = runtime.profile(name) + current = runtime.status(name) + status = current['status'] + if status == 'unknown': + raise RuntimeError(f'Cannot open {name}: unknown VM identity; {current.get("error", "inspect its QMP identity before retrying")}') + manifest = _view_preflight(runtime, no_view=no_view, manifest=manifest) + if status == 'stopped': + _memory_preflight(profile.memory) + runtime.start(name) + # Other verified QEMU states (for example paused) are viewable without a + # new process. Runtime.start owns the lock and final identity recheck. + return _show(runtime, name, no_view=no_view, windowed=windowed, manifest=manifest) + + +def run_source(runtime: Runtime, source: str | Path, *, name: str, memory=4096, + cpus=2, disk_size='40G', sha256: str | None = None, windowed=False, + no_view=False, manifest: Path | None = None, defaults: Path | None = None, + progress=None) -> int: + validate_name(name) + profile_path = runtime.config / f'{name}.toml' + if profile_path.exists() or profile_path.is_symlink(): + raise RuntimeError(f'VM {name} already exists; use hydra open {name}') + paths = runtime.paths(name) + if paths.disk.parent.exists() or paths.disk.parent.is_symlink(): + raise RuntimeError(f'Existing VM state/disk for {name}; choose a new name and preserve the old disk') + if runtime.status(name)['status'] != 'stopped': + raise RuntimeError(f'Cannot create {name}: existing or unknown VM identity') + preferences = load_defaults(defaults) if defaults is not None else {} + # Validate requested allocations before downloading or creating anything. + Profile(name, memory=memory, cpus=cpus, disk_size=disk_size, **preferences) + manifest = _view_preflight(runtime, no_view=no_view, manifest=manifest) + _memory_preflight(memory) + source = str(source) + if '://' in source: + iso = media.download_iso(source, sha256=sha256, progress=progress) + else: + iso = media.validate_iso(Path(source).expanduser().resolve(), sha256=sha256) + # Recheck after a potentially lengthy transfer. Profile publication itself + # is exclusive ('x') and Runtime never replaces an existing disk. + if paths.disk.parent.exists() or paths.disk.parent.is_symlink(): + raise RuntimeError(f'Existing VM state/disk for {name}; choose a new name') + _memory_preflight(memory) + runtime.add(Profile(name, iso, memory, cpus, disk_size, **preferences)) + runtime.start(name) + return _show(runtime, name, no_view=no_view, windowed=windowed, manifest=manifest) diff --git a/hydra/media.py b/hydra/media.py new file mode 100644 index 0000000..3bec458 --- /dev/null +++ b/hydra/media.py @@ -0,0 +1,164 @@ +"""Private, validated ISO downloads with atomic, non-replacing publication.""" + +import fcntl +import hashlib +import http.client +import os +import re +import shutil +import stat +import tempfile +import urllib.error +import urllib.parse +import urllib.request +from pathlib import Path + +from .runtime import _private_dir + + +class MediaError(ValueError): + """An actionable image error without remote query strings or credentials.""" + + +def _checksum(sha256: str | None) -> str | None: + if sha256 is not None and (not isinstance(sha256, str) or not re.fullmatch(r'[a-fA-F0-9]{64}', sha256)): + raise MediaError('SHA-256 must contain exactly 64 hexadecimal characters') + return sha256.lower() if sha256 else None + + +def _url(url: str) -> urllib.parse.SplitResult: + try: + parts = urllib.parse.urlsplit(url) + port = parts.port + except ValueError as exc: + raise MediaError('Invalid HTTP(S) image URL') from exc + if parts.scheme not in ('http', 'https') or not parts.hostname: + raise MediaError('Use a direct HTTP(S) ISO URL, or a local ISO path with hydra run') + if parts.username is not None or parts.password is not None: + raise MediaError('URL credentials are not supported; use a direct image URL without credentials') + if any(ord(char) < 32 or ord(char) == 127 for char in url): + raise MediaError('Image URL contains control characters') + return parts + + +class _SafeRedirect(urllib.request.HTTPRedirectHandler): + def redirect_request(self, req, fp, code, msg, headers, newurl): + _url(newurl) + return super().redirect_request(req, fp, code, msg, headers, newurl) + + +def validate_iso(path: Path, *, sha256: str | None = None) -> Path: + """Recognize ISO9660 or UDF volume descriptors and optional whole-file hash.""" + wanted = _checksum(sha256) + path = Path(path) + if not path.is_file(): + raise MediaError(f'ISO does not exist or is not a file: {path}') + found = False + with path.open('rb') as stream: + stream.seek(16 * 2048) + for _ in range(241): + sector = stream.read(2048) + if len(sector) != 2048: + break + if ((sector[1:6] == b'CD001' and sector[0] in (0, 1, 2, 3, 255) and sector[6] == 1) + or (sector[1:6] in (b'NSR02', b'NSR03') and sector[6] == 1)): + found = True + break + if not found: + raise MediaError('Not an ISO9660/UDF image; use a direct ISO download URL rather than an HTML download page') + if wanted: + stream.seek(0) + digest = hashlib.file_digest(stream, 'sha256').hexdigest() + if digest != wanted: + raise MediaError('ISO SHA-256 mismatch; image was not accepted') + return path + + +def _cached(path: Path, sha256: str | None) -> Path | None: + try: + info = path.lstat() + except FileNotFoundError: + return None + if not stat.S_ISREG(info.st_mode) or info.st_uid != os.getuid(): + raise MediaError('Refusing an unsafe ISO cache entry; choose another cache directory') + try: + return validate_iso(path, sha256=sha256) + except MediaError as exc: + raise MediaError('Cached ISO failed validation; move it aside or choose another cache directory') from exc + + +def download_iso(url: str, *, sha256: str | None = None, cache: Path | None = None, + progress=None) -> Path: + """Download once under a URL lock; publish only complete verified images.""" + parts = _url(url) + wanted = _checksum(sha256) + cache = Path(cache) if cache is not None else Path(os.environ.get('XDG_CACHE_HOME', Path.home() / '.cache')) / 'hydra/iso' + cache = cache.expanduser().absolute() + _private_dir(cache) + basename = re.sub(r'[^A-Za-z0-9._-]', '_', urllib.parse.unquote(Path(parts.path).name))[:96].strip('.') or 'image.iso' + if not basename.lower().endswith('.iso'): + basename += '.iso' + key = hashlib.sha256(url.encode()).hexdigest() + destination = cache / f'{key}-{basename}' + lock = os.open(cache / (key + '.lock'), os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW, 0o600) + try: + fcntl.flock(lock, fcntl.LOCK_EX) + cached = _cached(destination, wanted) + if cached: + if progress: + size = cached.stat().st_size + progress(size, size) + return cached + temporary = None + try: + opener = urllib.request.build_opener(_SafeRedirect()) + request = urllib.request.Request(url, headers={'User-Agent': 'Hydra-OS/1', 'Accept-Encoding': 'identity'}) + with opener.open(request, timeout=30) as response: + if response.status != 200: + raise MediaError(f'Image server returned HTTP {response.status}; use a direct ISO URL') + if response.headers.get_content_type() in ('text/html', 'application/xhtml+xml'): + raise MediaError('Server returned an HTML download page; use a direct ISO URL') + raw_length = response.headers.get('Content-Length') + try: + total = int(raw_length) if raw_length is not None else None + if total is not None and total <= 0: + raise ValueError + except ValueError as exc: + raise MediaError('Image server supplied an invalid content length') from exc + if total is not None and total > shutil.disk_usage(cache)[2]: + raise MediaError('Not enough free disk space for the ISO download') + fd, name = tempfile.mkstemp(prefix='.' + key + '-', suffix='.part', dir=cache) + temporary = Path(name) + received = 0 + with os.fdopen(fd, 'wb') as output: + if progress: + progress(0, total) + while True: + chunk = response.read(1024 * 1024) + if not chunk: + break + if len(chunk) > shutil.disk_usage(cache)[2]: + raise MediaError('Not enough free disk space for the ISO download') + received += len(chunk) + if total is not None and received > total: + raise MediaError('Image download exceeds its advertised content length') + output.write(chunk) + if progress: + progress(received, total) + output.flush() + os.fsync(output.fileno()) + if total is not None and received != total: + raise MediaError('Image download was truncated; retry the direct ISO URL') + validate_iso(temporary, sha256=wanted) + # Linking cannot replace a concurrent or unrelated destination. + os.link(temporary, destination) + return destination + except urllib.error.HTTPError as exc: + raise MediaError(f'Image server returned HTTP {exc.code}; use a direct ISO URL') from None + except (urllib.error.URLError, http.client.HTTPException, TimeoutError) as exc: + raise MediaError('ISO download failed or timed out; check networking and retry the direct URL') from None + finally: + if temporary is not None: + temporary.unlink(missing_ok=True) + finally: + os.close(lock) diff --git a/tests/test_cli.py b/tests/test_cli.py index c78b036..268adc6 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -64,3 +64,51 @@ class CLITests(unittest.TestCase): self.assertEqual(result.returncode, 1, result.stderr) self.assertNotIn('Traceback', result.stderr) self.assertFalse((self.root / 'config/guest.toml').exists()) + + def test_plain_noninteractive_invocation_prints_help_without_creating_state(self): + result = self.run_cli() + self.assertEqual(result.returncode, 0, result.stderr) + self.assertIn('Hydra OS', result.stdout) + self.assertIn('run', result.stdout) + self.assertFalse((self.root / 'state').exists()) + + def test_run_and_open_are_headless_and_existing_name_is_never_replaced(self): + import os + from unittest.mock import patch + from test_media import iso_bytes + from test_runtime import FAKE_IMG, FAKE_QEMU + from hydra.runtime import Runtime + iso = self.root / 'installer.iso' + iso.write_bytes(iso_bytes()) + qemu = self.root / 'qemu' + qemu.write_text(FAKE_QEMU) + qemu.chmod(0o700) + img = self.root / 'img' + img.write_text(FAKE_IMG) + img.chmod(0o700) + runtime = Runtime(self.root / 'config', self.root / 'state', self.root / 'run') + def cleanup(): + if runtime.status('guest')['status'] == 'running': + runtime.stop('guest', force=True) + self.addCleanup(cleanup) + with patch.dict(os.environ, {'HYDRA_QEMU': str(qemu), 'HYDRA_QEMU_IMG': str(img), + 'DISPLAY': '', 'WAYLAND_DISPLAY': '', 'HYDRA_SESSION_MANIFEST': '/not/a/manifest'}): + started = self.run_cli('run', str(iso), '--name', 'guest', '--memory', '256', '--cpus', '1', '--disk-size', '8M', '--no-view') + self.assertEqual(started.returncode, 0, started.stderr) + original = (self.root / 'config/guest.toml').read_bytes() + pid = runtime.paths('guest').pid.read_bytes() + duplicate = self.run_cli('run', str(iso), '--name', 'guest', '--no-view') + self.assertEqual(duplicate.returncode, 1, duplicate.stderr) + self.assertIn('hydra open guest', duplicate.stderr) + iso.unlink() + opened = self.run_cli('open', 'guest', '--no-view') + self.assertEqual(opened.returncode, 0, opened.stderr) + self.assertEqual((self.root / 'config/guest.toml').read_bytes(), original) + self.assertEqual(runtime.paths('guest').pid.read_bytes(), pid) + + def test_download_rejects_credential_urls_without_echoing_secrets(self): + result = self.run_cli('download', 'https://user:supersecret@example.org/installer.iso?token=private') + self.assertEqual(result.returncode, 1, result.stderr) + self.assertNotIn('supersecret', result.stderr) + self.assertNotIn('private', result.stderr) + self.assertNotIn('Traceback', result.stderr) diff --git a/tests/test_launch.py b/tests/test_launch.py new file mode 100644 index 0000000..bb5d3be --- /dev/null +++ b/tests/test_launch.py @@ -0,0 +1,176 @@ +import json +import os +import tempfile +import unittest +from pathlib import Path +from unittest.mock import patch + +from hydra import launch +from hydra.profiles import Profile +from hydra.runtime import Runtime, RuntimeError as VMError +from test_media import iso_bytes +from test_runtime import FAKE_IMG, FAKE_QEMU + + +class LaunchTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory(prefix='hl-') + self.addCleanup(self.tmp.cleanup) + self.root = Path(self.tmp.name) + self.runtime = Runtime(self.root / 'config', self.root / 'state', self.root / 'run', boot_timeout=1) + self.iso = self.root / 'installer.iso' + self.iso.write_bytes(iso_bytes()) + self.qemu = self.root / 'qemu' + self.qemu.write_text(FAKE_QEMU) + self.qemu.chmod(0o700) + self.img = self.root / 'img' + self.img.write_text(FAKE_IMG) + self.img.chmod(0o700) + self.viewer = self.root / 'viewer' + self.viewer.write_text('#!/bin/sh\necho --hide-toolbar\n') + self.viewer.chmod(0o700) + env = patch.dict(os.environ, {'HYDRA_QEMU': str(self.qemu), 'HYDRA_QEMU_IMG': str(self.img), 'HYDRA_VIEWER': str(self.viewer)}) + env.start() + self.addCleanup(env.stop) + memory = patch('hydra.launch.available_memory_mib', return_value=16384) + memory.start() + self.addCleanup(memory.stop) + self.addCleanup(self.cleanup_guests) + + def cleanup_guests(self): + for name in ('guest', 'other'): + if self.runtime.status(name)['status'] == 'running': + self.runtime.stop(name, force=True) + + def test_run_creates_and_starts_once_then_open_reconnects_without_memory_or_iso(self): + self.assertEqual(launch.run_source(self.runtime, str(self.iso), name='guest', memory=256, cpus=1, disk_size='8M', no_view=True), 0) + profile = self.runtime.profile('guest') + self.assertEqual((profile.memory, profile.cpus, profile.disk_size), (256, 1, '8M')) + pid = self.runtime.paths('guest').pid.read_bytes() + disk = self.runtime.paths('guest').disk.read_bytes() + self.iso.unlink() + with patch('hydra.launch.available_memory_mib', return_value=0): + self.assertEqual(launch.open_vm(self.runtime, 'guest', no_view=True), 0) + self.assertEqual(self.runtime.paths('guest').pid.read_bytes(), pid) + self.assertEqual(self.runtime.paths('guest').disk.read_bytes(), disk) + + def test_existing_profile_or_orphan_disk_refuses_run_without_changes(self): + self.runtime.add(Profile('guest', memory=256)) + original = (self.runtime.config / 'guest.toml').read_bytes() + with self.assertRaisesRegex(VMError, 'open guest'): + launch.run_source(self.runtime, str(self.iso), name='guest', no_view=True) + self.assertEqual((self.runtime.config / 'guest.toml').read_bytes(), original) + paths = self.runtime.paths('other') + paths.disk.parent.mkdir(parents=True) + paths.disk.write_text('valuable old disk') + with self.assertRaisesRegex(VMError, 'disk|state'): + launch.run_source(self.runtime, str(self.iso), name='other', no_view=True) + self.assertEqual(paths.disk.read_text(), 'valuable old disk') + self.assertFalse((self.runtime.config / 'other.toml').exists()) + + def test_memory_reserve_refuses_before_profile_or_disk_then_allows_boundary(self): + with patch('hydra.launch.available_memory_mib', return_value=1023): + with self.assertRaisesRegex(VMError, '768'): + launch.run_source(self.runtime, str(self.iso), name='guest', memory=256, no_view=True) + self.assertFalse(self.runtime.config.exists()) + with patch('hydra.launch.available_memory_mib', return_value=1024): + self.assertEqual(launch.run_source(self.runtime, str(self.iso), name='guest', memory=256, no_view=True), 0) + + def test_open_stopped_preserves_disk_and_checks_memory(self): + self.runtime.add(Profile('guest', memory=256)) + paths = self.runtime.paths('guest') + paths.disk.parent.mkdir(parents=True) + paths.disk.write_text('installed OS') + with patch('hydra.launch.available_memory_mib', return_value=1000): + with self.assertRaises(VMError): + launch.open_vm(self.runtime, 'guest', no_view=True) + self.assertEqual(self.runtime.status('guest')['status'], 'stopped') + self.assertEqual(launch.open_vm(self.runtime, 'guest', no_view=True), 0) + self.assertEqual(paths.disk.read_text(), 'installed OS') + + def test_missing_display_or_viewer_refuses_before_launch_side_effects(self): + with patch.dict(os.environ, {'DISPLAY': '', 'WAYLAND_DISPLAY': ''}): + with self.assertRaisesRegex(ValueError, 'display'): + launch.run_source(self.runtime, str(self.iso), name='guest', no_view=False) + self.assertFalse(self.runtime.config.exists()) + self.runtime.add(Profile('guest', memory=256)) + with patch.dict(os.environ, {'DISPLAY': ':1', 'HYDRA_VIEWER': '/does/not/exist'}): + with self.assertRaisesRegex(ValueError, 'Missing'): + launch.open_vm(self.runtime, 'guest') + self.assertFalse(self.runtime.paths('guest').disk.exists()) + self.assertEqual(self.runtime.status('guest')['status'], 'stopped') + + def test_unknown_identity_never_restarts_or_controls_vm(self): + launch.run_source(self.runtime, str(self.iso), name='guest', memory=256, no_view=True) + paths = self.runtime.paths('guest') + original = paths.metadata.read_text() + pid = paths.pid.read_bytes() + paths.metadata.write_text(json.dumps({'name': 'foreign', 'uuid': 'foreign'})) + try: + with self.assertRaisesRegex(VMError, 'unknown|identity'): + launch.open_vm(self.runtime, 'guest', no_view=True) + self.assertEqual(paths.pid.read_bytes(), pid) + finally: + paths.metadata.write_text(original) + + def test_invalid_local_iso_never_creates_profile(self): + self.iso.write_text('not an ISO') + with self.assertRaises(ValueError): + launch.run_source(self.runtime, str(self.iso), name='guest', no_view=True) + self.assertFalse(self.runtime.config.exists()) + + def test_session_open_writes_selected_refresh_and_preserves_other_guest(self): + self.runtime.add(Profile('guest', memory=256)) + self.runtime.add(Profile('other', memory=256)) + launch.open_vm(self.runtime, 'other', no_view=True) + other_pid = self.runtime.paths('other').pid.read_bytes() + directory = self.root / 'session' + directory.mkdir() + manifest = directory / 'session.json' + manifest.write_text(json.dumps({'config': str(self.runtime.config), 'state': str(self.runtime.state), + 'runtime': str(self.runtime.runtime), 'terminal': '/bin/sh', + 'directory': str(directory), 'names': ['guest', 'other']})) + with patch.dict(os.environ, {'DISPLAY': ':1', 'HYDRA_SESSION_MANIFEST': str(manifest)}): + self.assertEqual(launch.open_vm(self.runtime, 'guest'), 0) + self.assertEqual(json.loads((directory / 'refresh.request').read_text()), {'selected': 'guest'}) + self.assertEqual(self.runtime.status('guest')['status'], 'running') + self.assertEqual(self.runtime.paths('other').pid.read_bytes(), other_pid) + + def test_invalid_explicit_manifest_refuses_before_new_profile_and_environment_falls_back(self): + manifest = self.root / 'bad.json' + manifest.write_text('{}') + with patch.dict(os.environ, {'DISPLAY': ':1'}): + with self.assertRaisesRegex(ValueError, 'manifest'): + launch.run_source(self.runtime, str(self.iso), name='guest', manifest=manifest) + self.assertFalse(self.runtime.config.exists()) + with patch.dict(os.environ, {'HYDRA_SESSION_MANIFEST': str(manifest)}): + self.assertIsNone(launch.session_manifest(self.runtime)) + + def test_remote_run_downloads_then_launches_with_distribution_defaults(self): + import http.server + import threading + owner = self + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + body = iso_bytes() + self.send_response(200) + self.send_header('Content-Length', str(len(body))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + pass + server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + self.addCleanup(server.server_close) + thread = threading.Thread(target=server.serve_forever, daemon=True) + thread.start() + self.addCleanup(server.shutdown) + defaults = self.root / 'defaults.toml' + defaults.write_text('network="bridge"\nbridge="testbr0"\nwidth=1600\n') + with patch.dict(os.environ, {'XDG_CACHE_HOME': str(self.root / 'cache')}): + result = launch.run_source(self.runtime, f'http://127.0.0.1:{server.server_port}/os.iso', name='guest', + memory=256, no_view=True, defaults=defaults) + self.assertEqual(result, 0) + profile = self.runtime.profile('guest') + self.assertEqual((profile.network, profile.bridge, profile.width), ('bridge', 'testbr0', 1600)) + self.assertEqual(profile.iso.read_bytes(), iso_bytes()) + self.assertTrue(profile.iso.is_relative_to(self.root / 'cache/hydra/iso')) diff --git a/tests/test_media.py b/tests/test_media.py new file mode 100644 index 0000000..7d46cc6 --- /dev/null +++ b/tests/test_media.py @@ -0,0 +1,134 @@ +import hashlib +import http.server +import tempfile +import threading +import unittest +from pathlib import Path +from unittest.mock import patch + +from hydra import media + + +def iso_bytes(signature=b'CD001'): + data = bytearray(40 * 2048) + data[16 * 2048:16 * 2048 + 7] = b'\x01' + signature + b'\x01' + return bytes(data) + + +class MediaTests(unittest.TestCase): + def setUp(self): + self.tmp = tempfile.TemporaryDirectory() + self.addCleanup(self.tmp.cleanup) + self.cache = Path(self.tmp.name) / 'cache' + self.requests = [] + owner = self + class Handler(http.server.BaseHTTPRequestHandler): + def do_GET(self): + owner.requests.append(self.path) + body = b'download here' if self.path.startswith('/html') else iso_bytes() + self.send_response(200) + self.send_header('Content-Type', 'text/html' if self.path.startswith('/html') else 'application/octet-stream') + self.send_header('Content-Length', str(len(body) + (100 if self.path.startswith('/truncated') else 0))) + self.end_headers() + self.wfile.write(body) + def log_message(self, *args): + pass + self.server = http.server.ThreadingHTTPServer(('127.0.0.1', 0), Handler) + self.addCleanup(self.server.server_close) + self.thread = threading.Thread(target=self.server.serve_forever, daemon=True) + self.thread.start() + self.addCleanup(self.server.shutdown) + self.url = f'http://127.0.0.1:{self.server.server_port}' + + def test_verified_download_is_reused_and_progress_reports_complete_bytes(self): + updates = [] + url = self.url + '/installer.iso?token=secret' + path = media.download_iso(url, cache=self.cache, progress=lambda got, total: updates.append((got, total))) + self.assertEqual(path.read_bytes(), iso_bytes()) + self.assertEqual(path.stat().st_mode & 0o777, 0o600) + self.assertNotIn('secret', path.name) + self.assertEqual(updates[-1], (81920, 81920)) + self.assertEqual(media.download_iso(url, cache=self.cache), path) + self.assertEqual(len(self.requests), 1) + + def test_html_truncation_and_checksum_do_not_publish_images(self): + for endpoint, checksum in (('/html.iso', None), ('/truncated.iso', None), ('/wrong.iso', '0' * 64)): + with self.subTest(endpoint=endpoint): + with self.assertRaises(ValueError): + media.download_iso(self.url + endpoint, cache=self.cache, sha256=checksum) + self.assertFalse(list(self.cache.glob('*.iso'))) + self.assertFalse(list(self.cache.glob('*.part'))) + + def test_cancellation_removes_partial_and_retry_completes(self): + def cancel(got, total): + if got: + raise KeyboardInterrupt + with self.assertRaises(KeyboardInterrupt): + media.download_iso(self.url + '/cancel.iso', cache=self.cache, progress=cancel) + self.assertFalse(list(self.cache.glob('*.iso'))) + self.assertFalse(list(self.cache.glob('*.part'))) + self.assertEqual(media.download_iso(self.url + '/cancel.iso', cache=self.cache).read_bytes(), iso_bytes()) + + def test_same_basename_different_urls_and_concurrent_downloads_are_safe(self): + results = [] + errors = [] + def fetch(): + try: + results.append(media.download_iso(self.url + '/one/os.iso', cache=self.cache)) + except Exception as exc: + errors.append(exc) + threads = [threading.Thread(target=fetch) for _ in range(2)] + for thread in threads: + thread.start() + for thread in threads: + thread.join(5) + self.assertEqual(errors, []) + self.assertEqual(len(set(results)), 1) + self.assertEqual(self.requests, ['/one/os.iso']) + second = media.download_iso(self.url + '/two/os.iso', cache=self.cache) + self.assertNotEqual(second, results[0]) + + def test_corrupt_cache_and_symlink_never_overwrite_unrelated_files(self): + url = self.url + '/installer.iso' + path = media.download_iso(url, cache=self.cache) + path.write_text('valuable unrelated data') + with self.assertRaises(ValueError): + media.download_iso(url, cache=self.cache) + self.assertEqual(path.read_text(), 'valuable unrelated data') + path.unlink() + target = Path(self.tmp.name) / 'outside' + target.write_bytes(iso_bytes()) + path.symlink_to(target) + with self.assertRaises(ValueError): + media.download_iso(url, cache=self.cache) + self.assertTrue(path.is_symlink()) + self.assertEqual(target.read_bytes(), iso_bytes()) + + def test_unsupported_urls_checksums_and_insufficient_space_fail_before_download(self): + for url in ('ftp://example.org/os.iso', 'http://user:secret@example.org/os.iso'): + with self.assertRaises(ValueError): + media.download_iso(url, cache=self.cache) + with self.assertRaises(ValueError): + media.download_iso(self.url + '/os.iso', cache=self.cache, sha256='bad') + with patch('hydra.media.shutil.disk_usage', return_value=(10, 9, 1)): + with self.assertRaisesRegex(ValueError, 'space'): + media.download_iso(self.url + '/os.iso', cache=self.cache) + self.assertFalse(list(self.cache.glob('*.iso'))) + + def test_local_validation_accepts_udf_and_rejects_html_and_bad_checksum(self): + path = Path(self.tmp.name) / 'os.iso' + path.write_bytes(iso_bytes(b'NSR03')) + media.validate_iso(path, sha256=hashlib.sha256(path.read_bytes()).hexdigest()) + with self.assertRaises(ValueError): + media.validate_iso(path, sha256='0' * 64) + path.write_text('not an image') + with self.assertRaisesRegex(ValueError, 'direct|ISO'): + media.validate_iso(path) + + def test_cache_reuse_reports_completed_progress_without_another_request(self): + url = self.url + '/cached.iso' + path = media.download_iso(url, cache=self.cache) + updates = [] + self.assertEqual(media.download_iso(url, cache=self.cache, progress=lambda got, total: updates.append((got, total))), path) + self.assertEqual(updates, [(81920, 81920)]) + self.assertEqual(self.requests, ['/cached.iso'])