"""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 | 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 mac: str | None = None def __post_init__(self): validate_name(self.name) 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()) if self.mac is not None: if (not isinstance(self.mac, str) or not re.fullmatch(r'[0-9a-fA-F]{2}(:[0-9a-fA-F]{2}){5}', self.mac) or int(self.mac[:2], 16) & 3 != 2): raise ValueError('mac must be a locally administered unicast Ethernet address') object.__setattr__(self, 'mac', self.mac.lower()) 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 (self.refresh_millihz == 0 or 50000 <= self.refresh_millihz <= 255000): raise ValueError('refresh_millihz must be 0 (automatic) or an integer between 50000 and 255000') 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) 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