332 lines
15 KiB
Python
332 lines
15 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, replace
|
|
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']
|
|
blockdevs = [
|
|
{'driver': 'file', 'filename': str(paths.disk), 'node-name': 'disk-file'},
|
|
{'driver': 'qcow2', 'file': 'disk-file', 'node-name': 'disk'},
|
|
]
|
|
if profile.iso is not None:
|
|
blockdevs += [
|
|
{'driver': 'file', 'filename': str(profile.iso), 'node-name': 'iso-file', 'read-only': True},
|
|
{'driver': 'raw', 'file': 'iso-file', 'node-name': 'iso', 'read-only': True},
|
|
]
|
|
for options in blockdevs:
|
|
argv += ['-blockdev', json.dumps(options)]
|
|
argv += ['-device', 'virtio-blk-pci,drive=disk']
|
|
if profile.iso is not None:
|
|
argv += ['-device', 'ide-cd,drive=iso']
|
|
argv += ['-boot', 'order=dc' if profile.iso is not None else 'order=c', '-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')
|
|
mode = f',xres={profile.width},yres={profile.height}'
|
|
if profile.refresh_millihz:
|
|
mode += f',refresh_rate={profile.refresh_millihz}'
|
|
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' + mode]
|
|
else:
|
|
argv += ['-display', 'none', '-device', 'virtio-vga' + mode]
|
|
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:
|
|
self._validate_media(profile.iso)
|
|
_private_dir(self.config)
|
|
save_profile(self.config / f'{profile.name}.toml', profile)
|
|
|
|
@staticmethod
|
|
def _validate_media(iso: Path | None) -> None:
|
|
if iso is not None and not iso.is_file():
|
|
raise RuntimeError(f'ISO does not exist or is not a file: {iso}; attach valid media or detach it while stopped')
|
|
|
|
def media(self, name: str, iso: Path | None) -> dict:
|
|
paths = self.paths(name)
|
|
with self._locked(paths):
|
|
current = self.status(name)
|
|
if current['status'] != 'stopped':
|
|
raise RuntimeError(f'Cannot change media for {name}: {current["status"]}')
|
|
profile = replace(self.profile(name), iso=iso)
|
|
self._validate_media(profile.iso)
|
|
destination = self.config / f'{name}.toml'
|
|
if destination.is_symlink():
|
|
raise RuntimeError('Refusing to replace a symlink profile')
|
|
with tempfile.TemporaryDirectory(prefix=f'.{name}-', dir=self.config) as tmp:
|
|
temp = Path(tmp) / f'{name}.toml'
|
|
save_profile(temp, profile)
|
|
temp.chmod(0o600)
|
|
os.replace(temp, destination)
|
|
return {'name': name, 'iso': str(profile.iso) if profile.iso else None, 'status': 'media-updated'}
|
|
|
|
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:
|
|
try:
|
|
mode = path.lstat().st_mode
|
|
except FileNotFoundError:
|
|
return False
|
|
if not stat.S_ISSOCK(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:
|
|
paths = self.paths(name)
|
|
with self._locked(paths):
|
|
profile = self.profile(name)
|
|
current = self.status(name)
|
|
if current['status'] != 'stopped':
|
|
raise RuntimeError(f'Cannot start {name}: {current["status"]}')
|
|
self._validate_media(profile.iso)
|
|
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')
|