feat: add independent VM manager
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
"""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')
|
||||
Reference in New Issue
Block a user