various improvements
This commit is contained in:
		@@ -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)
 | 
			
		||||
 
 | 
			
		||||
@@ -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."""
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 
 | 
			
		||||
@@ -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."""
 | 
			
		||||
 
 | 
			
		||||
@@ -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."""
 | 
			
		||||
 
 | 
			
		||||
@@ -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
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -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())
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user