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