diff --git a/README.md b/README.md index 075c4e1..723b03c 100644 --- a/README.md +++ b/README.md @@ -20,13 +20,13 @@ Run `make serve-docs`. See [Development](#development) below. - [x] CPU customization (emulation mode, model, vendor, features) - [ ] BIOS/UEFI settings - [x] Device attaching -- [ ] Device detaching +- [x] Device detaching - [ ] GPU passthrough - [ ] CPU guarantied resource percent support - [x] QEMU Guest Agent management - [ ] Instance resources usage stats - [ ] SSH-keys management -- [x] Setting user passwords in guest [not tested] +- [x] Setting user passwords in guest - [x] QCOW2 disks support - [ ] ZVOL support - [ ] Network disks support diff --git a/compute/cli/control.py b/compute/cli/control.py index 1f91b12..93ad959 100644 --- a/compute/cli/control.py +++ b/compute/cli/control.py @@ -234,13 +234,20 @@ def main(session: Session, args: argparse.Namespace) -> None: case 'setmem': instance = session.get_instance(args.instance) instance.set_memory(args.memory, live=True) + case 'setpasswd': + instance = session.get_instance(args.instance) + instance.set_user_password( + args.username, + args.password, + encrypted=args.encrypted, + ) def cli() -> None: # noqa: PLR0915 - """Parse command line arguments.""" + """Return command line arguments parser.""" root = argparse.ArgumentParser( prog='compute', - description='manage compute instances and storage volumes.', + description='manage compute instances', formatter_class=argparse.RawTextHelpFormatter, ) root.add_argument( @@ -383,13 +390,27 @@ def cli() -> None: # noqa: PLR0915 setmem.add_argument('instance') setmem.add_argument('memory', type=int, help='memory in MiB') - # Run parser + # setpasswd subcommand + setpasswd = subparsers.add_parser( + 'setpasswd', + help='set user password in guest', + ) + setpasswd.add_argument('instance') + setpasswd.add_argument('username') + setpasswd.add_argument('password') + setpasswd.add_argument( + '-e', + '--encrypted', + action='store_true', + default=False, + help='set it if password is already encrypted', + ) + args = root.parse_args() if args.command is None: root.print_help() sys.exit() - # Set logging level log_level = args.log_level or os.getenv('CMP_LOG') if isinstance(log_level, str) and log_level.lower() in log_levels: @@ -398,7 +419,6 @@ def cli() -> None: # noqa: PLR0915 ) log.debug('CLI started with args: %s', args) - # Perform actions try: with Session(args.connect) as session: main(session, args) diff --git a/compute/exceptions.py b/compute/exceptions.py index 25948e7..0528afd 100644 --- a/compute/exceptions.py +++ b/compute/exceptions.py @@ -37,6 +37,22 @@ class StoragePoolError(ComputeServiceError): """Something went wrong when operating with storage pool.""" +class StoragePoolNotFoundError(StoragePoolError): + """Storage pool not found.""" + + def __init__(self, msg: str): + """Initialise StoragePoolNotFoundError.""" + super().__init__(f"storage pool named '{msg}' not found") + + +class VolumeNotFoundError(StoragePoolError): + """Storage volume not found.""" + + def __init__(self, msg: str): + """Initialise VolumeNotFoundError.""" + super().__init__(f"storage volume '{msg}' not found") + + class InstanceError(ComputeServiceError): """Something went wrong while interacting with the domain.""" diff --git a/compute/instance/guest_agent.py b/compute/instance/guest_agent.py index d5b7e99..5355ae3 100644 --- a/compute/instance/guest_agent.py +++ b/compute/instance/guest_agent.py @@ -78,8 +78,8 @@ class GuestAgent: except GuestAgentError: return False - def available_commands(self) -> set[str]: - """Return set of available guest agent commands.""" + def get_supported_commands(self) -> set[str]: + """Return set of supported guest agent commands.""" output = self.execute({'execute': 'guest-info', 'arguments': {}}) return { cmd['name'] @@ -94,8 +94,9 @@ class GuestAgent: :param commands: List of required commands :raise: GuestAgentCommandNotSupportedError """ + supported = self.get_supported_commands() for command in commands: - if command not in self.available_commands(): + if command not in supported: raise GuestAgentCommandNotSupportedError(command) def guest_exec( # noqa: PLR0913 diff --git a/compute/instance/instance.py b/compute/instance/instance.py index f6c7e50..143caed 100644 --- a/compute/instance/instance.py +++ b/compute/instance/instance.py @@ -13,6 +13,7 @@ from compute.exceptions import ( GuestAgentCommandNotSupportedError, InstanceError, ) +from compute.storage import DiskConfig from compute.utils import units from .guest_agent import GuestAgent @@ -181,7 +182,7 @@ class InstanceInfo(NamedTuple): class DeviceConfig: - """Abstract device description class.""" + """Abstract device config class.""" class Instance: @@ -485,6 +486,11 @@ class Instance: msg = f'Cannot set memory for instance={self.name} {memory=}: {e}' raise InstanceError(msg) from e + def _get_disk_by_target(self, target: str) -> etree.Element: + xml = etree.fromstring(self.dump_xml()) # noqa: S320 + child = xml.xpath(f'/domain/devices/disk/target[@dev="{target}"]') + return child[0].getparent() if child else None + def attach_device( self, device: 'DeviceConfig', *, live: bool = False ) -> None: @@ -501,6 +507,13 @@ class Instance: ) else: flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG + if isinstance(device, DiskConfig): # noqa: SIM102 + if self._get_disk_by_target(device.target): + log.warning( + "Volume with target '%s' is already attached", + device.target, + ) + return self.domain.attachDeviceFlags(device.to_xml(), flags=flags) def detach_device( @@ -519,8 +532,48 @@ class Instance: ) else: flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG + if isinstance(device, DiskConfig): # noqa: SIM102 + if self._get_disk_by_target(device.target) is None: + log.warning( + "Volume with target '%s' is already detached", + device.target, + ) + return self.domain.detachDeviceFlags(device.to_xml(), flags=flags) + def detach_disk(self, name: str) -> None: + """ + Detach disk device by target name. + + There is no ``attach_disk()`` method. Use :method:`attach_device` + with :class:`DiskConfig` as parameter. + + :param name: Disk name e.g. 'vda', 'sda', etc. This name may + not match the name of the disk inside the guest OS. + """ + xml = self._get_disk_by_target(name) + if xml is None: + log.warning( + "Volume with target '%s' is already detached", + name, + ) + return + disk_params = { + 'disk_type': xml.get('type'), + 'source': xml.find('source').get('file'), + 'target': xml.find('target').get('dev'), + 'readonly': False if xml.find('readonly') is None else True, # noqa: SIM211 + } + for param in disk_params: + if disk_params[param] is None: + msg = ( + f"Cannot detach volume with target '{name}': " + f"parameter '{param}' is not defined in libvirt XML " + 'config on host.' + ) + raise InstanceError(msg) + self.detach_device(DiskConfig(**disk_params), live=True) + def resize_volume( self, name: str, capacity: int, unit: units.DataUnit ) -> None: @@ -573,7 +626,9 @@ class Instance: """ raise NotImplementedError - def set_user_password(self, user: str, password: str) -> None: + def set_user_password( + self, user: str, password: str, *, encrypted: bool = False + ) -> None: """ Set new user password in guest OS. @@ -581,8 +636,16 @@ class Instance: :param user: Username. :param password: Password. + :param encrypted: Set it to True if password is already encrypted. + Right encryption method depends on guest OS. """ - self.domain.setUserPassword(user, password) + if not self.guest_agent.is_available(): + raise InstanceError( + 'Cannot change password: guest agent is unavailable' + ) + self.guest_agent.raise_for_commands(['guest-set-user-password']) + flags = libvirt.VIR_DOMAIN_PASSWORD_ENCRYPTED if encrypted else 0 + self.domain.setUserPassword(user, password, flags=flags) def dump_xml(self, *, inactive: bool = False) -> str: """Return instance XML description.""" diff --git a/compute/session.py b/compute/session.py index d335fc6..7506fc5 100644 --- a/compute/session.py +++ b/compute/session.py @@ -10,7 +10,11 @@ from uuid import uuid4 import libvirt from lxml import etree -from .exceptions import InstanceNotFoundError, SessionError +from .exceptions import ( + InstanceNotFoundError, + SessionError, + StoragePoolNotFoundError, +) from .instance import Instance, InstanceConfig, InstanceSchema from .storage import DiskConfig, StoragePool, VolumeConfig from .utils import units @@ -23,10 +27,14 @@ class Capabilities(NamedTuple): """Store domain capabilities info.""" arch: str - virt: str + virt_type: str emulator: str machine: str max_vcpus: int + cpu_vendor: str + cpu_model: str + cpu_features: dict + usable_cpus: list[dict] class NodeInfo(NamedTuple): @@ -101,16 +109,47 @@ class Session(AbstractContextManager): threads=info[7], ) + def _cap_get_usable_cpus(self, xml: etree.Element) -> list[dict]: + x = xml.xpath('/domainCapabilities/cpu/mode[@name="custom"]')[0] + cpus = [] + for cpu in x.findall('model'): + if cpu.get('usable') == 'yes': + cpus.append( # noqa: PERF401 + { + 'vendor': cpu.get('vendor'), + 'model': cpu.text, + } + ) + return cpus + + def _cap_get_cpu_features(self, xml: etree.Element) -> dict: + x = xml.xpath('/domainCapabilities/cpu/mode[@name="host-model"]')[0] + require = [] + disable = [] + for feature in x.findall('feature'): + policy = feature.get('policy') + name = feature.get('name') + if policy == 'require': + require.append(name) + if policy == 'disable': + disable.append(name) + return {'require': require, 'disable': disable} + def get_capabilities(self) -> Capabilities: """Return capabilities e.g. arch, virt, emulator, etc.""" prefix = '/domainCapabilities' + hprefix = f'{prefix}/cpu/mode[@name="host-model"]' caps = etree.fromstring(self.connection.getDomainCapabilities()) # noqa: S320 return Capabilities( arch=caps.xpath(f'{prefix}/arch/text()')[0], - virt=caps.xpath(f'{prefix}/domain/text()')[0], + virt_type=caps.xpath(f'{prefix}/domain/text()')[0], emulator=caps.xpath(f'{prefix}/path/text()')[0], machine=caps.xpath(f'{prefix}/machine/text()')[0], max_vcpus=int(caps.xpath(f'{prefix}/vcpu/@max')[0]), + cpu_vendor=caps.xpath(f'{hprefix}/vendor/text()')[0], + cpu_model=caps.xpath(f'{hprefix}/model/text()')[0], + cpu_features=self._cap_get_cpu_features(caps), + usable_cpus=self._cap_get_cpus(caps), ) def create_instance(self, **kwargs: Any) -> Instance: @@ -169,7 +208,7 @@ class Session(AbstractContextManager): volumes_pool = self.get_storage_pool(self.VOLUMES_POOL) log.info('Building volume configuration...') if not volume.source: - vol_name = f'{config.name}-{volume.target}-{uuid4()}.qcow2' + vol_name = f'{uuid4()}.qcow2' else: vol_name = volume.source vol_conf = VolumeConfig( @@ -196,7 +235,12 @@ class Session(AbstractContextManager): volumes_pool.create_volume(vol_conf) log.info('Attaching volume to instance...') instance.attach_device( - DiskConfig(path=vol_conf.path, target=volume.target) + DiskConfig( + disk_type=volume.type, + source=vol_conf.path, + target=volume.target, + readonly=volume.is_readonly, + ) ) return instance @@ -204,10 +248,10 @@ class Session(AbstractContextManager): """Get compute instance by name.""" try: return Instance(self.connection.lookupByName(name)) - except libvirt.libvirtError as err: - if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN: - raise InstanceNotFoundError(name) from err - raise SessionError(err) from err + except libvirt.libvirtError as e: + if e.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN: + raise InstanceNotFoundError(name) from e + raise SessionError(e) from e def list_instances(self) -> list[Instance]: """List all instances.""" @@ -215,8 +259,12 @@ class Session(AbstractContextManager): def get_storage_pool(self, name: str) -> StoragePool: """Get storage pool by name.""" - # TODO @ge: handle Storage pool not found error - return StoragePool(self.connection.storagePoolLookupByName(name)) + try: + return StoragePool(self.connection.storagePoolLookupByName(name)) + except libvirt.libvirtError as e: + if e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_POOL: + raise StoragePoolNotFoundError(name) from e + raise SessionError(e) from e def list_storage_pools(self) -> list[StoragePool]: """List all strage pools.""" diff --git a/compute/storage/pool.py b/compute/storage/pool.py index 04e340e..0a11d3a 100644 --- a/compute/storage/pool.py +++ b/compute/storage/pool.py @@ -7,7 +7,7 @@ from typing import NamedTuple import libvirt from lxml import etree -from compute.exceptions import StoragePoolError +from compute.exceptions import StoragePoolError, VolumeNotFoundError from .volume import Volume, VolumeConfig @@ -99,13 +99,8 @@ class StoragePool: vol = self.pool.storageVolLookupByName(name) return Volume(self.pool, vol) except libvirt.libvirtError as e: - # TODO @ge: Raise VolumeNotFoundError instead - if ( - e.get_error_domain() == libvirt.VIR_FROM_STORAGE - or e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL - ): - log.exception(e.get_error_message()) - return None + if e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL: + raise VolumeNotFoundError(name) from e log.exception('unexpected error from libvirt') raise StoragePoolError(e) from e diff --git a/compute/storage/volume.py b/compute/storage/volume.py index 10417da..b7dfaea 100644 --- a/compute/storage/volume.py +++ b/compute/storage/volume.py @@ -56,15 +56,17 @@ class DiskConfig: to compute instances. """ + disk_type: str + source: str | Path target: str - path: str readonly: bool = False def to_xml(self) -> str: """Return XML config for libvirt.""" - xml = E.disk(type='file', device='disk') + xml = E.disk(type=self.disk_type, device='disk') xml.append(E.driver(name='qemu', type='qcow2', cache='writethrough')) - xml.append(E.source(file=self.path)) + if self.disk_type == 'file': + xml.append(E.source(file=str(self.source))) xml.append(E.target(dev=self.target, bus='virtio')) if self.readonly: xml.append(E.readonly()) diff --git a/docs/source/index.rst b/docs/source/index.rst index 569bdfe..81222c2 100644 --- a/docs/source/index.rst +++ b/docs/source/index.rst @@ -1,12 +1,12 @@ Compute ======= -Compute-instance management library. +Compute instances management library. .. toctree:: :maxdepth: 1 - python-api/index + pyapi/index Indices and tables ------------------ diff --git a/docs/source/python-api/exceptions.rst b/docs/source/pyapi/exceptions.rst similarity index 100% rename from docs/source/python-api/exceptions.rst rename to docs/source/pyapi/exceptions.rst diff --git a/docs/source/python-api/index.rst b/docs/source/pyapi/index.rst similarity index 100% rename from docs/source/python-api/index.rst rename to docs/source/pyapi/index.rst diff --git a/docs/source/python-api/instance/guest_agent.rst b/docs/source/pyapi/instance/guest_agent.rst similarity index 100% rename from docs/source/python-api/instance/guest_agent.rst rename to docs/source/pyapi/instance/guest_agent.rst diff --git a/docs/source/python-api/instance/index.rst b/docs/source/pyapi/instance/index.rst similarity index 100% rename from docs/source/python-api/instance/index.rst rename to docs/source/pyapi/instance/index.rst diff --git a/docs/source/python-api/instance/instance.rst b/docs/source/pyapi/instance/instance.rst similarity index 100% rename from docs/source/python-api/instance/instance.rst rename to docs/source/pyapi/instance/instance.rst diff --git a/docs/source/python-api/instance/schemas.rst b/docs/source/pyapi/instance/schemas.rst similarity index 100% rename from docs/source/python-api/instance/schemas.rst rename to docs/source/pyapi/instance/schemas.rst diff --git a/docs/source/python-api/session.rst b/docs/source/pyapi/session.rst similarity index 100% rename from docs/source/python-api/session.rst rename to docs/source/pyapi/session.rst diff --git a/docs/source/python-api/storage/index.rst b/docs/source/pyapi/storage/index.rst similarity index 100% rename from docs/source/python-api/storage/index.rst rename to docs/source/pyapi/storage/index.rst diff --git a/docs/source/python-api/storage/pool.rst b/docs/source/pyapi/storage/pool.rst similarity index 100% rename from docs/source/python-api/storage/pool.rst rename to docs/source/pyapi/storage/pool.rst diff --git a/docs/source/python-api/storage/volume.rst b/docs/source/pyapi/storage/volume.rst similarity index 100% rename from docs/source/python-api/storage/volume.rst rename to docs/source/pyapi/storage/volume.rst diff --git a/docs/source/python-api/utils.rst b/docs/source/pyapi/utils.rst similarity index 100% rename from docs/source/python-api/utils.rst rename to docs/source/pyapi/utils.rst diff --git a/dom.xml b/dom.xml new file mode 100644 index 0000000..f59ef80 --- /dev/null +++ b/dom.xml @@ -0,0 +1,227 @@ + + debian12 + 721cf06b-879a-4f4e-875b-b3d0382328ef + + + + + + 4194304 + 4194304 + 4 + + /machine + + + hvm + + + + + + + + + + + + + + destroy + restart + destroy + + + + + + /usr/bin/qemu-system-x86_64 + + + + + + +
+ + + +
+ + + + + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + + + +
+ + + +
+ + + +
+ + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ + + + +
+ + + +
+ + + + + + + + + + + + + +
+ +