Files
Hydra-OS/hydra/runtime.py
T

296 lines
13 KiB
Python

"""Independent QEMU lifecycle; all destructive control requires QMP identity."""
import contextlib
import errno
import fcntl
import json
import os
import socket
import stat
import subprocess
import tempfile
import time
import uuid
from dataclasses import dataclass
from pathlib import Path
from .profiles import Profile, load_profile, save_profile, validate_name
from .qmp import QMPClient, QMPError
class RuntimeError(Exception):
"""A VM lifecycle operation could not be safely completed."""
@dataclass(frozen=True)
class VMPaths:
name: str
disk: Path
log: Path
lock: Path
qmp: Path
spice: Path
pid: Path
metadata: Path
@property
def uuid(self) -> str:
return str(uuid.uuid5(uuid.NAMESPACE_URL, str(self.disk)))
def _qemu_value(value: Path | str) -> str:
# QEMU's key/value parser uses doubled commas for literal commas.
return str(value).replace(',', ',,')
def _intel_render_node() -> Path | None:
for node in sorted(Path('/dev/dri').glob('renderD*')):
vendor = Path('/sys/class/drm') / node.name / 'device/vendor'
try:
if vendor.read_text().strip() == '0x8086' and os.access(node, os.R_OK | os.W_OK):
return node
except OSError:
continue
return None
def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
accel = 'kvm' if os.access('/dev/kvm', os.R_OK | os.W_OK) else 'tcg'
argv = [os.environ.get('HYDRA_QEMU', 'qemu-system-x86_64'), '-name', profile.name,
'-uuid', paths.uuid, '-machine', f'q35,accel={accel}',
'-m', str(profile.memory), '-smp', str(profile.cpus),
'-daemonize', '-pidfile', str(paths.pid), '-nodefaults',
'-chardev', f'socket,id=qmp,path={_qemu_value(paths.qmp)},server=on,wait=off',
'-mon', 'chardev=qmp,mode=control']
if accel == 'kvm':
argv += ['-cpu', 'host']
for options in (
{'driver': 'file', 'filename': str(paths.disk), 'node-name': 'disk-file'},
{'driver': 'qcow2', 'file': 'disk-file', 'node-name': 'disk'},
{'driver': 'file', 'filename': str(profile.iso), 'node-name': 'iso-file', 'read-only': True},
{'driver': 'raw', 'file': 'iso-file', 'node-name': 'iso', 'read-only': True},
):
argv += ['-blockdev', json.dumps(options)]
argv += ['-device', 'virtio-blk-pci,drive=disk', '-device', 'ide-cd,drive=iso',
'-boot', 'order=dc', '-device', 'virtio-net-pci,netdev=net',
'-netdev', 'user,id=net' if profile.network == 'user'
else f'bridge,id=net,br={profile.bridge}',
'-device', 'qemu-xhci,id=usb', '-device', 'usb-tablet', '-device', 'usb-kbd']
argv += ['-audiodev', 'spice,id=audio', '-device', 'ich9-intel-hda',
'-device', 'hda-duplex,audiodev=audio']
for channel in range(3):
argv += ['-chardev', f'spicevmc,id=usbredir{channel},name=usbredir',
'-device', f'usb-redir,chardev=usbredir{channel}']
spice = (f'unix=on,addr={_qemu_value(paths.spice)},disable-ticketing=on,'
'disable-copy-paste=on,disable-agent-file-xfer=on')
render = _intel_render_node() if profile.graphics == 'auto' else None
if render:
spice += f',gl=on,rendernode={_qemu_value(render)}'
argv += ['-display', f'egl-headless,rendernode={_qemu_value(render)}', '-device', 'virtio-vga-gl']
else:
argv += ['-display', 'none', '-device', 'virtio-vga']
return argv + ['-spice', spice]
def _private_dir(path: Path) -> None:
if path.is_symlink():
raise RuntimeError(f'Private directory must not be a symlink: {path}')
path.mkdir(parents=True, mode=0o700, exist_ok=True)
info = path.stat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != os.getuid():
raise RuntimeError(f'Private directory is not owned by this user: {path}')
path.chmod(0o700)
class Runtime:
def __init__(self, config: Path, state: Path, runtime: Path, boot_timeout: float = 10):
# Keep final components lexical so symlink checks still see them.
self.config = Path(config).expanduser().absolute()
self.state = Path(state).expanduser().absolute()
self.runtime = Path(runtime).expanduser().absolute()
self.boot_timeout = boot_timeout
def paths(self, name: str) -> VMPaths:
validate_name(name)
state = self.state / name
run = self.runtime / name
paths = VMPaths(name, state / 'disk.qcow2', state / 'qemu.log', state / 'lock',
run / 'qmp.sock', run / 'spice.sock', run / 'qemu.pid', run / 'identity.json')
if any(len(os.fsencode(path)) >= 108 for path in (paths.qmp, paths.spice)):
raise RuntimeError('Unix socket path is too long; choose a shorter --runtime path')
return paths
def prepare(self, paths: VMPaths) -> None:
for directory in (self.config, self.state, self.runtime, paths.disk.parent, paths.qmp.parent):
_private_dir(directory)
def add(self, profile: Profile) -> None:
_private_dir(self.config)
save_profile(self.config / f'{profile.name}.toml', profile)
def profiles(self) -> list[Profile]:
if not self.config.exists():
return []
return [load_profile(path) for path in sorted(self.config.glob('*.toml'))]
def profile(self, name: str) -> Profile:
validate_name(name)
return load_profile(self.config / f'{name}.toml')
@contextlib.contextmanager
def _locked(self, paths: VMPaths):
self.prepare(paths)
fd = os.open(paths.lock, os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
try:
fcntl.flock(fd, fcntl.LOCK_EX)
yield
finally:
os.close(fd)
def _owned_process_alive(self, paths: VMPaths) -> bool:
# PID is only a diagnostic hint. Never send a signal to it.
try:
pid = int(paths.pid.read_text())
args = Path(f'/proc/{pid}/cmdline').read_bytes().split(b'\0')
wanted_uuid = paths.uuid.encode()
wanted_name = paths.name.encode()
return (b'-uuid' in args and args[args.index(b'-uuid') + 1] == wanted_uuid
and b'-name' in args and args[args.index(b'-name') + 1] == wanted_name)
except (OSError, ValueError, IndexError):
return False
def _connect(self, paths: VMPaths) -> QMPClient:
try:
record = json.loads(paths.metadata.read_text())
if not isinstance(record, dict) or record.get('name') != paths.name or record.get('uuid') != paths.uuid:
raise RuntimeError('VM identity record does not match selected VM')
client = QMPClient(paths.qmp, timeout=min(2, self.boot_timeout))
try:
name_result = client.command('query-name')
uuid_result = client.command('query-uuid')
if not isinstance(name_result, dict) or not isinstance(uuid_result, dict):
raise RuntimeError('Malformed QMP identity reply')
name = name_result.get('name')
actual_uuid = uuid_result.get('UUID')
if name != paths.name or actual_uuid != record['uuid']:
raise RuntimeError('QMP VM identity mismatch; refusing control')
except Exception:
client.close()
raise
return client
except (OSError, ValueError, QMPError) as exc:
raise RuntimeError(f'Cannot verify VM identity: {exc}') from exc
def _listening(self, path: Path) -> bool:
if not path.exists():
return False
if not stat.S_ISSOCK(path.lstat().st_mode):
raise RuntimeError(f'Refusing non-socket runtime path: {path}')
with socket.socket(socket.AF_UNIX) as probe:
probe.settimeout(min(1, self.boot_timeout))
try:
probe.connect(str(path))
return True
except OSError as exc:
if exc.errno in (errno.ENOENT, errno.ECONNREFUSED):
return False
raise RuntimeError(f'Cannot establish socket ownership: {exc}') from exc
def status(self, name: str) -> dict:
paths = self.paths(name)
try:
if not self._listening(paths.qmp):
if self._owned_process_alive(paths):
return {'name': name, 'status': 'unknown', 'error': 'VM process exists without working QMP'}
return {'name': name, 'status': 'stopped'}
with self._connect(paths) as client:
status = client.command('query-status')
if not isinstance(status, dict) or not isinstance(status.get('status'), str):
raise RuntimeError('Malformed QMP status reply')
return {'name': name, **status}
except (RuntimeError, QMPError) as exc:
return {'name': name, 'status': 'unknown', 'error': str(exc)}
def _create_disk(self, profile: Profile, paths: VMPaths) -> None:
if paths.disk.exists() or paths.disk.is_symlink():
if paths.disk.is_symlink() or not paths.disk.is_file():
raise RuntimeError(f'Refusing non-regular disk: {paths.disk}')
return
fd, temporary = tempfile.mkstemp(prefix='.disk-', suffix='.qcow2', dir=paths.disk.parent)
os.close(fd)
temp = Path(temporary)
temp.unlink() # qemu-img creates a new file; final publication never replaces a disk.
try:
subprocess.run([os.environ.get('HYDRA_QEMU_IMG', 'qemu-img'), 'create', '-f', 'qcow2',
str(temp), profile.disk_size], check=True, capture_output=True,
text=True, timeout=30)
temp.chmod(0o600)
os.link(temp, paths.disk)
except (OSError, subprocess.SubprocessError) as exc:
raise RuntimeError(f'Disk creation failed: {exc}') from exc
finally:
temp.unlink(missing_ok=True)
def start(self, name: str) -> dict:
profile = self.profile(name)
paths = self.paths(name)
with self._locked(paths):
current = self.status(name)
if current['status'] != 'stopped':
raise RuntimeError(f'Cannot start {name}: {current["status"]}')
for path in (paths.qmp, paths.spice):
if self._listening(path):
raise RuntimeError(f'Runtime socket is already in use: {path}')
path.unlink(missing_ok=True)
self._create_disk(profile, paths)
fd, temporary = tempfile.mkstemp(prefix='.identity-', dir=paths.metadata.parent)
try:
with os.fdopen(fd, 'w') as record:
record.write(json.dumps({'name': name, 'uuid': paths.uuid}) + '\n')
os.replace(temporary, paths.metadata)
finally:
Path(temporary).unlink(missing_ok=True)
paths.pid.unlink(missing_ok=True)
try:
log_fd = os.open(paths.log, os.O_CREAT | os.O_APPEND | os.O_WRONLY | os.O_NOFOLLOW, 0o600)
with os.fdopen(log_fd, 'ab') as log:
subprocess.run(build_qemu_argv(profile, paths), stdin=subprocess.DEVNULL,
stdout=log, stderr=log, start_new_session=True,
check=True, timeout=self.boot_timeout,
umask=0o077)
deadline = time.monotonic() + self.boot_timeout
while time.monotonic() < deadline:
current = self.status(name)
if current['status'] not in ('stopped', 'unknown'):
return current
time.sleep(0.05)
raise RuntimeError(f'QEMU did not become ready; inspect {paths.log}')
except (OSError, subprocess.SubprocessError) as exc:
raise RuntimeError(f'QEMU launch failed; inspect {paths.log}: {exc}') from exc
def shutdown(self, name: str) -> dict:
paths = self.paths(name)
try:
with self._locked(paths), self._connect(paths) as client:
client.command('system_powerdown')
except QMPError as exc:
raise RuntimeError(f'Guest shutdown request failed: {exc}') from exc
return {'name': name, 'status': 'shutdown-requested'}
def stop(self, name: str, force: bool = False) -> dict:
if not force:
raise RuntimeError('Abrupt stop requires --force; use shutdown for guest ACPI shutdown')
paths = self.paths(name)
with self._locked(paths):
try:
with self._connect(paths) as client:
client.command('quit')
except QMPError as exc:
raise RuntimeError(f'QEMU quit request failed: {exc}') from exc
deadline = time.monotonic() + self.boot_timeout
while time.monotonic() < deadline:
if self.status(name)['status'] == 'stopped':
return {'name': name, 'status': 'stopped'}
time.sleep(0.05)
raise RuntimeError('QEMU accepted quit but has not stopped within the deadline')