Files

177 lines
9.4 KiB
Python

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