Files
Hydra-OS/tests/test_runtime.py
T

395 lines
18 KiB
Python

import json
import os
import socket
import subprocess
import sys
import tempfile
import threading
import unittest
from pathlib import Path
from unittest.mock import patch
from hydra.profiles import Profile, save_profile
from hydra.runtime import Runtime, RuntimeError as VMError, build_qemu_argv
from test_qmp import QMPServer, greeting, send
# External protocol fixtures isolate lifecycle tests from user VMs and real disks.
FAKE_QEMU = r'''#!/usr/bin/env python3
import json, os, socket, sys
args = sys.argv[1:]
def val(flag): return args[args.index(flag)+1]
if os.environ.get('HYDRA_TEST_FAIL'):
print('intentional boot failure', file=sys.stderr)
sys.exit(1)
path = val('-chardev').split('path=', 1)[1].split(',server=', 1)[0].replace(',,', ',')
pid = os.fork()
if pid:
sys.exit(0)
os.setsid()
with open(val('-pidfile'), 'w') as f: f.write(str(os.getpid()))
s = socket.socket(socket.AF_UNIX)
s.bind(path)
s.listen(8)
alive = True
while alive:
c, _ = s.accept()
try:
with c:
c.sendall(b'{"QMP":{"version":{"qemu":{"major":10,"minor":0,"micro":0},"package":""},"capabilities":[]}}\n')
with c.makefile('rb') as stream:
for line in stream:
cmd = json.loads(line)
name = cmd['execute']
result = {'query-name': {'name': val('-name')}, 'query-uuid': {'UUID': val('-uuid')},
'query-status': {'status': 'running', 'running': True, 'singlestep': False}}.get(name, {})
c.sendall(json.dumps({'return': result, 'id': cmd['id']}).encode()+b'\n')
if name == 'quit':
alive = False
break
except (BrokenPipeError, ConnectionResetError):
pass
s.close()
os.unlink(path)
'''
FAKE_IMG = r'''#!/usr/bin/env python3
import sys
with open(sys.argv[-2], 'xb') as f: f.write(b'new qcow2 fixture')
'''
class RuntimeTests(unittest.TestCase):
def setUp(self):
self.tmp = tempfile.TemporaryDirectory(prefix='hr-')
self.addCleanup(self.tmp.cleanup)
self.root = Path(self.tmp.name)
self.runtime = Runtime(self.root / 'config space,comma', self.root / 'state space,comma',
self.root / 'run,comma', boot_timeout=1)
self.iso = self.root / 'installer space,comma.iso'
self.iso.touch()
self.profile = Profile('guest', self.iso, graphics='software', disk_size='8M')
self.runtime.add(self.profile)
self.qemu = self.root / 'fake qemu'
self.qemu.write_text(FAKE_QEMU)
self.qemu.chmod(0o700)
self.img = self.root / 'fake img'
self.img.write_text(FAKE_IMG)
self.img.chmod(0o700)
env = patch.dict(os.environ, {'HYDRA_QEMU': str(self.qemu), 'HYDRA_QEMU_IMG': str(self.img)})
env.start()
self.addCleanup(env.stop)
self.addCleanup(self.cleanup_guest)
def cleanup_guest(self):
try:
if self.runtime.status('guest')['status'] == 'running':
self.runtime.stop('guest', force=True)
except VMError:
pass
def test_mac_persisted_distinct_and_stable_across_restarts(self):
self.runtime.add(Profile('other', network='bridge'))
other = self.runtime.profile('other')
guest = self.runtime.profile('guest')
self.assertIsNotNone(guest.mac)
self.assertNotEqual(guest.mac, other.mac)
octet = int(guest.mac.split(':')[0], 16)
self.assertEqual(octet & 3, 2) # locally administered, unicast
def nic(profile, runtime):
return next(arg for arg in build_qemu_argv(profile, runtime.paths(profile.name))
if arg.startswith('virtio-net-pci,'))
expected = nic(guest, self.runtime)
self.runtime.start('guest')
self.runtime.stop('guest', force=True)
self.runtime.start('guest')
self.runtime.stop('guest', force=True)
self.assertEqual(nic(self.runtime.profile('guest'), self.runtime), expected)
changed_runtime = Runtime(self.runtime.config, self.runtime.state, self.root / 'different-run')
self.assertEqual(nic(changed_runtime.profile('guest'), changed_runtime), expected)
self.runtime.media('guest', None)
self.assertEqual(self.runtime.profile('guest').mac, guest.mac)
def test_legacy_profiles_gain_stable_owner_specific_argv_mac(self):
file = self.runtime.config / 'legacy.toml'
save_profile(file, Profile('legacy', network='bridge'))
original = file.read_bytes()
legacy = self.runtime.profile('legacy')
self.assertIsNone(legacy.mac)
paths = self.runtime.paths('legacy')
def mac():
return next(arg for arg in build_qemu_argv(legacy, paths) if arg.startswith('virtio-net-pci,'))
with patch('os.getuid', return_value=1000):
first = mac()
self.assertEqual(mac(), first)
self.assertNotEqual(first, next(arg for arg in build_qemu_argv(
legacy, self.runtime.paths('other')) if arg.startswith('virtio-net-pci,')))
with patch('os.getuid', return_value=1001):
self.assertNotEqual(mac(), first)
self.assertEqual(file.read_bytes(), original)
def test_disk_boot_omits_media_and_requests_display(self):
profile = Profile('guest', width=1920, height=1080, refresh_millihz=144000,
graphics='software')
argv = build_qemu_argv(profile, self.runtime.paths('guest'))
self.assertIn('virtio-vga,xres=1920,yres=1080,refresh_rate=144000', argv)
self.assertNotIn('ide-cd,drive=iso', argv)
self.assertIn('order=c', argv)
devices = [json.loads(argv[i+1]) for i, item in enumerate(argv) if item == '-blockdev']
self.assertEqual(len(devices), 2)
stock = build_qemu_argv(Profile('guest', graphics='software'), self.runtime.paths('guest'))
self.assertFalse(any('refresh_rate=' in arg for arg in stock))
def test_missing_iso_is_error_at_start_without_creating_disk(self):
self.iso.unlink()
with self.assertRaisesRegex(VMError, 'ISO'):
self.runtime.start('guest')
self.assertFalse(self.runtime.paths('guest').disk.exists())
self.assertEqual(self.runtime.status('guest')['status'], 'stopped')
def test_media_changes_only_while_stopped_and_preserves_disk(self):
paths = self.runtime.paths('guest')
self.runtime.start('guest')
disk = paths.disk.read_bytes()
config = (self.runtime.config / 'guest.toml').read_bytes()
for iso in (None, self.iso):
with self.assertRaises(VMError):
self.runtime.media('guest', iso)
self.assertEqual((self.runtime.config / 'guest.toml').read_bytes(), config)
self.iso.unlink()
self.assertEqual(self.runtime.profile('guest').iso, self.iso)
self.runtime.stop('guest', force=True)
self.runtime.media('guest', None)
self.assertIsNone(self.runtime.profile('guest').iso)
self.runtime.start('guest')
self.runtime.stop('guest', force=True)
self.assertEqual(paths.disk.read_bytes(), disk)
replacement = self.root / 'replacement.iso'
replacement.touch()
self.runtime.media('guest', replacement)
self.assertEqual(self.runtime.profile('guest').iso, replacement)
with self.assertRaisesRegex(VMError, 'ISO'):
self.runtime.media('guest', self.iso)
self.assertEqual(self.runtime.profile('guest').iso, replacement)
def test_argv_is_private_and_comma_safe(self):
paths = self.runtime.paths('guest')
argv = build_qemu_argv(self.profile, paths)
self.assertEqual(argv[0], str(self.qemu))
blockdevs = [json.loads(argv[i+1]) for i, item in enumerate(argv) if item == '-blockdev']
self.assertIn(str(paths.disk), [b.get('filename') for b in blockdevs])
self.assertIn(str(self.iso), [b.get('filename') for b in blockdevs])
spice = argv[argv.index('-spice')+1]
self.assertIn('unix=on', spice)
self.assertIn('disable-copy-paste=on', spice)
self.assertIn('disable-agent-file-xfer=on', spice)
self.assertIn('run,,comma', spice)
self.assertIn('virtio-blk-pci,drive=disk', argv)
self.assertTrue(any(arg.startswith('virtio-net-pci,netdev=net,mac=') for arg in argv))
self.assertNotIn('-vnc', argv)
self.assertNotIn('-virtfs', argv)
def test_socket_removed_during_stat_is_safely_not_listening(self):
paths = self.runtime.paths('guest')
paths.qmp.parent.mkdir(parents=True)
with socket.socket(socket.AF_UNIX) as fixture:
fixture.bind(str(paths.qmp))
original_lstat = Path.lstat
def disappearing_socket(path):
if path == paths.qmp:
path.unlink(missing_ok=True)
return original_lstat(path)
with patch.object(Path, 'lstat', disappearing_socket):
self.assertFalse(self.runtime._listening(paths.qmp))
def test_argv_provides_audio_and_manual_usb_channels(self):
argv = build_qemu_argv(self.profile, self.runtime.paths('guest'))
self.assertIn('spice,id=audio', argv)
self.assertIn('hda-duplex,audiodev=audio', argv)
for channel in range(3):
self.assertIn(f'spicevmc,id=usbredir{channel},name=usbredir', argv)
self.assertIn(f'usb-redir,chardev=usbredir{channel}', argv)
self.assertFalse(any('usb-host' in argument for argument in argv))
def test_metadata_symlink_does_not_overwrite_user_data(self):
paths = self.runtime.paths('guest')
self.runtime.prepare(paths)
valuable = self.root / 'valuable data'
valuable.write_text('preserve me')
paths.metadata.symlink_to(valuable)
self.runtime.start('guest')
self.assertEqual(valuable.read_text(), 'preserve me')
self.assertFalse(paths.metadata.is_symlink())
def test_disk_symlink_never_changes_its_target(self):
paths = self.runtime.paths('guest')
self.runtime.prepare(paths)
valuable = self.root / 'valuable disk'
valuable.write_text('preserve me')
paths.disk.symlink_to(valuable)
with self.assertRaises(VMError):
self.runtime.start('guest')
self.assertEqual(valuable.read_text(), 'preserve me')
def test_malformed_identity_record_is_safe_unknown(self):
paths = self.runtime.paths('guest')
self.runtime.prepare(paths)
paths.metadata.write_text('[]')
server = QMPServer(paths.qmp, greeting)
self.addCleanup(server.close)
self.assertEqual(self.runtime.status('guest')['status'], 'unknown')
def test_foreign_qmp_name_blocks_control_even_when_uuid_matches(self):
paths = self.runtime.paths('guest')
self.runtime.prepare(paths)
paths.metadata.write_text(json.dumps({'name': 'guest', 'uuid': paths.uuid}))
commands = []
def handler(conn):
greeting(conn)
for line in conn.makefile('rb'):
req = json.loads(line)
commands.append(req['execute'])
result = {'query-name': {'name': 'other'}, 'query-uuid': {'UUID': paths.uuid}}.get(req['execute'], {})
send(conn, {'return': result, 'id': req['id']})
server = QMPServer(paths.qmp, handler)
self.addCleanup(server.close)
with self.assertRaises(VMError):
self.runtime.stop('guest', force=True)
self.assertNotIn('quit', commands)
def test_reused_pid_without_qmp_never_controls_a_process(self):
paths = self.runtime.paths('guest')
self.runtime.prepare(paths)
paths.pid.write_text(str(os.getpid()))
self.assertEqual(self.runtime.status('guest')['status'], 'stopped')
with patch('os.kill', side_effect=AssertionError('must not signal any PID')):
with self.assertRaises(VMError):
self.runtime.stop('guest', force=True)
def test_malformed_query_result_is_safe_unknown(self):
paths = self.runtime.paths('guest')
self.runtime.prepare(paths)
paths.metadata.write_text(json.dumps({'name': 'guest', 'uuid': paths.uuid}))
def handler(conn):
greeting(conn)
for line in conn.makefile('rb'):
req = json.loads(line)
send(conn, {'return': [], 'id': req['id']})
server = QMPServer(paths.qmp, handler)
self.addCleanup(server.close)
self.assertEqual(self.runtime.status('guest')['status'], 'unknown')
def test_cli_qmp_failure_reports_error_without_traceback(self):
paths = self.runtime.paths('guest')
self.runtime.prepare(paths)
paths.metadata.write_text(json.dumps({'name': 'guest', 'uuid': paths.uuid}))
def handler(conn):
greeting(conn)
for line in conn.makefile('rb'):
req = json.loads(line)
result = {'query-name': {'name': 'guest'}, 'query-uuid': {'UUID': paths.uuid}}.get(req['execute'], {})
if req['execute'] == 'system_powerdown':
send(conn, {'error': {'class': 'GenericError', 'desc': 'cannot power down'}, 'id': req['id']})
else:
send(conn, {'return': result, 'id': req['id']})
server = QMPServer(paths.qmp, handler)
self.addCleanup(server.close)
run = subprocess.run([sys.executable, 'bin/hydra', '--config', str(self.runtime.config),
'--state', str(self.runtime.state), '--runtime', str(self.runtime.runtime),
'shutdown', 'guest'], capture_output=True, text=True, timeout=4)
self.assertEqual(run.returncode, 1)
self.assertIn('cannot power down', run.stderr)
self.assertNotIn('Traceback', run.stderr)
def test_disk_and_profile_survive_shutdown_and_force(self):
paths = self.runtime.paths('guest')
paths.disk.parent.mkdir(parents=True, exist_ok=True)
paths.disk.write_bytes(b'existing valuable disk')
self.runtime.start('guest')
self.assertEqual(self.runtime.status('guest')['status'], 'running')
self.runtime.shutdown('guest')
self.assertEqual(self.runtime.status('guest')['status'], 'running')
with self.assertRaises(VMError):
self.runtime.stop('guest', force=False)
self.runtime.stop('guest', force=True)
self.assertEqual(self.runtime.status('guest')['status'], 'stopped')
self.assertEqual(paths.disk.read_bytes(), b'existing valuable disk')
def test_simultaneous_start_only_launches_one_vm(self):
barrier = threading.Barrier(2)
results = []
def start():
barrier.wait()
try:
results.append(self.runtime.start('guest')['status'])
except VMError as exc:
results.append(str(exc))
threads = [threading.Thread(target=start) for _ in range(2)]
for thread in threads:
thread.start()
for thread in threads:
thread.join(4)
self.assertFalse(thread.is_alive())
self.assertEqual(results.count('running'), 1)
self.assertEqual(len(results), 2)
self.assertEqual(self.runtime.paths('guest').disk.read_bytes(), b'new qcow2 fixture')
def test_stale_socket_is_replaced_only_when_no_listener(self):
paths = self.runtime.paths('guest')
self.runtime.prepare(paths)
stale = socket.socket(socket.AF_UNIX)
stale.bind(str(paths.qmp))
stale.close()
self.runtime.start('guest')
self.assertEqual(self.runtime.status('guest')['status'], 'running')
def test_failed_boot_is_reported_and_disk_preserved(self):
with patch.dict(os.environ, {'HYDRA_TEST_FAIL': '1'}), self.assertRaises(VMError):
self.runtime.start('guest')
self.assertEqual(self.runtime.status('guest')['status'], 'stopped')
self.assertEqual(self.runtime.paths('guest').disk.read_bytes(), b'new qcow2 fixture')
self.assertIn('intentional boot failure', self.runtime.paths('guest').log.read_text())
def test_foreign_qmp_uuid_blocks_all_controls_and_restart(self):
paths = self.runtime.paths('guest')
self.runtime.prepare(paths)
paths.metadata.write_text(json.dumps({'name': 'guest', 'uuid': paths.uuid}))
paths.pid.write_text(str(os.getpid()))
commands = []
def handler(conn):
greeting(conn)
for line in conn.makefile('rb'):
req = json.loads(line)
commands.append(req['execute'])
result = {'query-name': {'name': 'guest'}, 'query-uuid': {'UUID': 'foreign-uuid'}}.get(req['execute'], {})
send(conn, {'return': result, 'id': req['id']})
server = QMPServer(paths.qmp, handler)
self.addCleanup(server.close)
self.assertEqual(self.runtime.status('guest')['status'], 'unknown')
for control in (lambda: self.runtime.shutdown('guest'), lambda: self.runtime.stop('guest', force=True),
lambda: self.runtime.start('guest')):
with self.assertRaises(VMError):
control()
self.assertNotIn('quit', commands)
self.assertNotIn('system_powerdown', commands)
self.assertFalse(paths.disk.exists())
def test_private_runtime_and_socket_length(self):
self.runtime.prepare(self.runtime.paths('guest'))
self.assertEqual(self.runtime.runtime.stat().st_mode & 0o777, 0o700)
with self.assertRaises(VMError):
Runtime(self.root / 'c', self.root / 's', self.root / ('r' * 100)).paths('guest')
def test_cli_lifecycle_and_global_paths(self):
base = [sys.executable, 'bin/hydra', '--config', str(self.runtime.config), '--state', str(self.runtime.state),
'--runtime', str(self.runtime.runtime)]
for args, expected in [(['list'], 'guest'), (['start', 'guest'], 'running'),
(['status', 'guest'], 'running'), (['stop', 'guest', '--force'], 'stopped')]:
run = subprocess.run(base + args, text=True, capture_output=True, timeout=4)
self.assertEqual(run.returncode, 0, run.stderr)
self.assertIn(expected, run.stdout)
if __name__ == '__main__':
unittest.main()