Add safe ISO download and explicit run/open commands

This commit is contained in:
Codex
2026-10-13 15:14:10 +03:00
parent 1784f3a8ce
commit e6dc59b567
6 changed files with 706 additions and 4 deletions
+61 -4
View File
@@ -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
+123
View File
@@ -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)
+164
View File
@@ -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)
+48
View File
@@ -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)
+176
View File
@@ -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('<html>not an ISO</html>')
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'))
+134
View File
@@ -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'<html>download here</html>' 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('<html>not an image</html>')
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'])