"""Independent QEMU lifecycle; all destructive control requires QMP identity.""" import contextlib import errno import fcntl import hashlib 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 _stable_mac(paths: VMPaths) -> str: # Disk/state identity survives restarts and runtime socket-directory changes. # UID distinguishes owners even when a shared bridge sees the same names. digest = bytearray(hashlib.sha256(f'{os.getuid()}:{paths.disk}'.encode()).digest()[:6]) digest[0] = (digest[0] & 0xfc) | 0x02 return ':'.join(f'{octet:02x}' for octet in digest) 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', f'virtio-net-pci,netdev=net,mac={profile.mac or _stable_mac(paths)}', '-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) if profile.mac is None: profile = replace(profile, mac=_stable_mac(self.paths(profile.name))) 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')