feat: add independent VM manager

This commit is contained in:
Codex
2026-10-13 04:04:30 +03:00
parent e6bec555c4
commit d7d01a4520
9 changed files with 1020 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
"""HydraOS independent VM management."""
+60
View File
@@ -0,0 +1,60 @@
"""Portable source-checkout command line interface."""
import argparse
import json
import os
import sys
from pathlib import Path
from .profiles import Profile
from .runtime import Runtime, RuntimeError
def parser() -> argparse.ArgumentParser:
cli = argparse.ArgumentParser(prog='hydra', description='Independent HydraOS VM manager')
cli.add_argument('--config', type=Path, default=Path(os.environ.get('XDG_CONFIG_HOME', Path.home() / '.config')) / 'hydra')
cli.add_argument('--state', type=Path, default=Path(os.environ.get('XDG_STATE_HOME', Path.home() / '.local/state')) / 'hydra')
cli.add_argument('--runtime', type=Path, default=Path(os.environ.get('XDG_RUNTIME_DIR', f'/tmp/hydra-{os.getuid()}')) / 'hydra')
commands = cli.add_subparsers(dest='command', required=True)
add = commands.add_parser('add', help='Create a VM profile without starting it')
add.add_argument('name')
add.add_argument('--iso', type=Path, required=True)
add.add_argument('--memory', type=int, default=4096)
add.add_argument('--cpus', type=int, default=2)
add.add_argument('--disk-size', default='40G')
add.add_argument('--graphics', choices=('auto', 'software'), default='auto')
add.add_argument('--network', choices=('user', 'bridge'), default='user')
add.add_argument('--bridge', default='hydrabr0')
commands.add_parser('list', help='List configured VMs')
status = commands.add_parser('status', help='Query VM state through private QMP')
status.add_argument('name', nargs='?')
for command in ('start', 'shutdown', 'stop'):
cmd = commands.add_parser(command)
cmd.add_argument('name')
if command == 'stop':
cmd.add_argument('--force', action='store_true', required=True)
return cli
def main(argv: list[str] | None = None) -> int:
args = parser().parse_args(argv)
runtime = Runtime(args.config, args.state, args.runtime)
try:
if args.command == 'add':
profile = Profile(args.name, args.iso, args.memory, args.cpus, args.disk_size,
args.graphics, args.network, args.bridge)
runtime.add(profile)
result = {'name': profile.name, 'status': 'added'}
elif args.command == 'list':
result = [{'name': profile.name, 'iso': str(profile.iso)} for profile in runtime.profiles()]
elif args.command == 'status':
result = runtime.status(args.name) if args.name else [runtime.status(p.name) for p in runtime.profiles()]
elif args.command == 'stop':
result = runtime.stop(args.name, force=args.force)
else:
result = getattr(runtime, args.command)(args.name)
print(json.dumps(result, indent=2))
return 0
except (RuntimeError, OSError, ValueError) as exc:
print(f'hydra: {exc}', file=sys.stderr)
return 1
+72
View File
@@ -0,0 +1,72 @@
"""Validated, independently editable VM profiles."""
import json
import re
import tomllib
from dataclasses import dataclass
from pathlib import Path
def validate_name(name: str) -> str:
if not isinstance(name, str) or not re.fullmatch(r'[a-z][a-z0-9-]{0,31}', name):
raise ValueError('VM name must match [a-z][a-z0-9-]{0,31}')
return name
@dataclass(frozen=True)
class Profile:
name: str
iso: Path
memory: int = 4096
cpus: int = 2
disk_size: str = '40G'
graphics: str = 'auto'
network: str = 'user'
bridge: str = 'hydrabr0'
def __post_init__(self):
validate_name(self.name)
iso = Path(self.iso).expanduser().resolve()
if not iso.is_file():
raise ValueError(f'ISO does not exist or is not a file: {iso}')
object.__setattr__(self, 'iso', iso)
for key, upper in (('memory', 262144), ('cpus', 256)):
value = getattr(self, key)
if type(value) is not int or not 1 <= value <= upper:
raise ValueError(f'{key} must be an integer between 1 and {upper}')
if not isinstance(self.disk_size, str) or not re.fullmatch(r'[1-9][0-9]{0,5}[MGT]', self.disk_size):
raise ValueError('disk_size must be a positive size with M, G or T suffix')
if self.graphics not in ('auto', 'software'):
raise ValueError('graphics must be auto or software')
if self.network not in ('user', 'bridge'):
raise ValueError('network must be user or bridge')
if not isinstance(self.bridge, str) or not re.fullmatch(r'[a-zA-Z0-9_-]{1,15}', self.bridge):
raise ValueError('bridge must be a valid Linux interface name')
def load_profile(path: Path) -> Profile:
path = Path(path)
with path.open('rb') as stream:
data = tomllib.load(stream)
unknown = data.keys() - Profile.__dataclass_fields__.keys()
if unknown:
raise ValueError(f'Unknown profile fields: {", ".join(sorted(unknown))}')
name = data.pop('name', path.stem)
if name != path.stem:
raise ValueError('Profile name must match its filename')
try:
return Profile(name=name, **data)
except TypeError as exc:
raise ValueError(f'Invalid profile: {exc}') from exc
def save_profile(path: Path, profile: Profile) -> None:
path = Path(path)
if path.stem != profile.name:
raise ValueError('Profile name must match its filename')
lines = []
for field in Profile.__dataclass_fields__:
value = getattr(profile, field)
lines.append(f'{field} = {value if type(value) is int else json.dumps(str(value), ensure_ascii=False)}\n')
with path.open('x', encoding='utf-8') as stream:
stream.write(''.join(lines))
+91
View File
@@ -0,0 +1,91 @@
"""Bounded JSON QMP over private Unix sockets, with reply ID matching."""
import json
import socket
import time
from pathlib import Path
class QMPError(RuntimeError):
pass
class QMPClient:
def __init__(self, path: Path, timeout: float = 2.0):
if timeout <= 0:
raise ValueError('QMP timeout must be positive')
self.timeout = timeout
self.socket = socket.socket(socket.AF_UNIX)
self.buffer = bytearray()
self.counter = 0
deadline = time.monotonic() + timeout
try:
self.socket.settimeout(timeout)
self.socket.connect(str(path))
greeting = self._receive(deadline)
if not isinstance(greeting.get('QMP'), dict):
raise QMPError('Invalid QMP greeting')
self._command('qmp_capabilities', None, deadline)
except (OSError, QMPError) as exc:
self.close()
if isinstance(exc, QMPError):
raise
raise QMPError(f'QMP connection failed: {exc}') from exc
def _receive(self, deadline: float) -> dict:
while True:
remaining = deadline - time.monotonic()
if remaining <= 0:
raise QMPError('QMP deadline exceeded')
if b'\n' in self.buffer:
line, _, rest = self.buffer.partition(b'\n')
self.buffer = bytearray(rest)
try:
message = json.loads(line)
except (ValueError, UnicodeDecodeError) as exc:
raise QMPError('Malformed QMP JSON') from exc
if not isinstance(message, dict):
raise QMPError('QMP response must be an object')
return message
self.socket.settimeout(remaining)
try:
chunk = self.socket.recv(65536)
except OSError as exc:
raise QMPError(f'QMP receive failed: {exc}') from exc
if not chunk:
raise QMPError('QMP connection closed')
self.buffer.extend(chunk)
if len(self.buffer) > 1048576:
raise QMPError('QMP message exceeds 1 MiB')
def _command(self, name: str, arguments: dict | None, deadline: float) -> dict:
self.counter += 1
request = {'execute': name, 'id': self.counter}
if arguments is not None:
request['arguments'] = arguments
try:
self.socket.settimeout(max(0.001, deadline - time.monotonic()))
self.socket.sendall(json.dumps(request).encode() + b'\r\n')
except OSError as exc:
raise QMPError(f'QMP send failed: {exc}') from exc
while True:
response = self._receive(deadline)
if response.get('id') != self.counter:
continue
if 'error' in response:
raise QMPError(f'QMP {name}: {response["error"]}')
if 'return' not in response:
raise QMPError('QMP reply has no return value')
return response['return']
def command(self, name: str, arguments: dict | None = None) -> dict:
return self._command(name, arguments, time.monotonic() + self.timeout)
def close(self):
self.socket.close()
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
+295
View File
@@ -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')