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
+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'])