feat: add independent VM manager
This commit is contained in:
@@ -0,0 +1 @@
|
||||
"""HydraOS independent VM management."""
|
||||
@@ -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
|
||||
@@ -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))
|
||||
@@ -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()
|
||||
@@ -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