feat: support installed guests and distribution display defaults
This commit is contained in:
+22
-6
@@ -7,7 +7,7 @@ import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from .profiles import Profile
|
||||
from .profiles import Profile, load_defaults
|
||||
from .runtime import Runtime, RuntimeError
|
||||
from .session import launch_session, session_helper, view
|
||||
|
||||
@@ -17,16 +17,26 @@ def parser() -> argparse.ArgumentParser:
|
||||
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')
|
||||
cli.add_argument('--defaults', type=Path, default=Path('/etc/hydra/defaults.toml'),
|
||||
help='Distribution preferences for newly created profiles')
|
||||
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('--iso', type=Path)
|
||||
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')
|
||||
add.add_argument('--network', choices=('user', 'bridge'))
|
||||
add.add_argument('--bridge')
|
||||
add.add_argument('--width', type=int)
|
||||
add.add_argument('--height', type=int)
|
||||
add.add_argument('--refresh-millihz', type=int)
|
||||
media = commands.add_parser('media', help='Attach or detach installation media while stopped')
|
||||
media.add_argument('name')
|
||||
selection = media.add_mutually_exclusive_group(required=True)
|
||||
selection.add_argument('--iso', type=Path)
|
||||
selection.add_argument('--detach', action='store_true')
|
||||
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='?')
|
||||
@@ -57,12 +67,18 @@ def main(argv: list[str] | None = None) -> int:
|
||||
elif args.command == '_session-helper':
|
||||
return session_helper(args.operation, args.manifest)
|
||||
elif args.command == 'add':
|
||||
preferences = load_defaults(args.defaults)
|
||||
preferences.update({key: getattr(args, key) for key in
|
||||
('network', 'bridge', 'width', 'height', 'refresh_millihz')
|
||||
if getattr(args, key) is not None})
|
||||
profile = Profile(args.name, args.iso, args.memory, args.cpus, args.disk_size,
|
||||
args.graphics, args.network, args.bridge)
|
||||
args.graphics, **preferences)
|
||||
runtime.add(profile)
|
||||
result = {'name': profile.name, 'status': 'added'}
|
||||
elif args.command == 'media':
|
||||
result = runtime.media(args.name, None if args.detach else args.iso)
|
||||
elif args.command == 'list':
|
||||
result = [{'name': profile.name, 'iso': str(profile.iso)} for profile in runtime.profiles()]
|
||||
result = [{'name': profile.name, 'iso': str(profile.iso) if profile.iso else None} 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':
|
||||
|
||||
+29
-5
@@ -16,24 +16,32 @@ def validate_name(name: str) -> str:
|
||||
@dataclass(frozen=True)
|
||||
class Profile:
|
||||
name: str
|
||||
iso: Path
|
||||
iso: Path | None = None
|
||||
memory: int = 4096
|
||||
cpus: int = 2
|
||||
disk_size: str = '40G'
|
||||
graphics: str = 'auto'
|
||||
network: str = 'user'
|
||||
bridge: str = 'hydrabr0'
|
||||
width: int = 1280
|
||||
height: int = 800
|
||||
refresh_millihz: int = 0
|
||||
|
||||
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)
|
||||
if self.iso is not None:
|
||||
if not isinstance(self.iso, (str, Path)) or not str(self.iso):
|
||||
raise ValueError('ISO must be a nonempty path or omitted')
|
||||
object.__setattr__(self, 'iso', Path(self.iso).expanduser().resolve())
|
||||
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}')
|
||||
for key in ('width', 'height'):
|
||||
if type(getattr(self, key)) is not int or not 1 <= getattr(self, key) <= 16384:
|
||||
raise ValueError(f'{key} must be an integer between 1 and 16384')
|
||||
if type(self.refresh_millihz) is not int or not 0 <= self.refresh_millihz <= 1000000:
|
||||
raise ValueError('refresh_millihz must be an integer between 0 and 1000000')
|
||||
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'):
|
||||
@@ -67,6 +75,22 @@ def save_profile(path: Path, profile: Profile) -> None:
|
||||
lines = []
|
||||
for field in Profile.__dataclass_fields__:
|
||||
value = getattr(profile, field)
|
||||
if value is None:
|
||||
continue
|
||||
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))
|
||||
|
||||
|
||||
def load_defaults(path: Path) -> dict:
|
||||
"""Distribution preferences apply only when creating a new profile."""
|
||||
try:
|
||||
with Path(path).open('rb') as stream:
|
||||
data = tomllib.load(stream)
|
||||
except FileNotFoundError:
|
||||
return {}
|
||||
unknown = data.keys() - {'network', 'bridge', 'width', 'height', 'refresh_millihz'}
|
||||
if unknown:
|
||||
raise ValueError(f'Unknown defaults fields: {", ".join(sorted(unknown))}')
|
||||
Profile('defaults-check', **data)
|
||||
return data
|
||||
|
||||
+42
-8
@@ -11,7 +11,7 @@ import subprocess
|
||||
import tempfile
|
||||
import time
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
|
||||
from .profiles import Profile, load_profile, save_profile, validate_name
|
||||
@@ -64,15 +64,21 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
||||
'-mon', 'chardev=qmp,mode=control']
|
||||
if accel == 'kvm':
|
||||
argv += ['-cpu', 'host']
|
||||
for options in (
|
||||
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', '-device', 'ide-cd,drive=iso',
|
||||
'-boot', 'order=dc', '-device', 'virtio-net-pci,netdev=net',
|
||||
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']
|
||||
@@ -83,12 +89,15 @@ def build_qemu_argv(profile: Profile, paths: VMPaths) -> list[str]:
|
||||
'-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']
|
||||
argv += ['-display', f'egl-headless,rendernode={_qemu_value(render)}', '-device', 'virtio-vga-gl' + mode]
|
||||
else:
|
||||
argv += ['-display', 'none', '-device', 'virtio-vga']
|
||||
argv += ['-display', 'none', '-device', 'virtio-vga' + mode]
|
||||
return argv + ['-spice', spice]
|
||||
|
||||
|
||||
@@ -125,9 +134,33 @@ class Runtime:
|
||||
_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 []
|
||||
@@ -234,12 +267,13 @@ class Runtime:
|
||||
temp.unlink(missing_ok=True)
|
||||
|
||||
def start(self, name: str) -> dict:
|
||||
profile = self.profile(name)
|
||||
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}')
|
||||
|
||||
Reference in New Issue
Block a user