various improvements
This commit is contained in:
@ -1,4 +1,4 @@
|
||||
"""Compute Service library."""
|
||||
"""Compute instances management library."""
|
||||
|
||||
__version__ = '0.1.0'
|
||||
|
||||
|
@ -1,26 +0,0 @@
|
||||
import argparse
|
||||
|
||||
from compute import Session
|
||||
from compute.utils import identifiers
|
||||
|
||||
|
||||
def _create_instance(session: Session, args: argparse.Namespace) -> None:
|
||||
"""
|
||||
Умолчания (достать информацию из либвирта):
|
||||
- arch
|
||||
- machine
|
||||
- emulator
|
||||
- CPU
|
||||
- cpu_vendor
|
||||
- cpu_model
|
||||
- фичи
|
||||
- max_memory
|
||||
- max_vcpus
|
||||
|
||||
(сегнерировать):
|
||||
- MAC адрес
|
||||
- boot_order = ('cdrom', 'hd')
|
||||
- title = ''
|
||||
- name = uuid.uuid4().hex
|
||||
"""
|
||||
print(args)
|
@ -1,12 +1,18 @@
|
||||
"""Command line interface."""
|
||||
|
||||
import argparse
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
from collections import UserDict
|
||||
from typing import Any
|
||||
from uuid import uuid4
|
||||
|
||||
import libvirt
|
||||
import yaml
|
||||
from pydantic import ValidationError
|
||||
|
||||
from compute import __version__
|
||||
from compute.exceptions import (
|
||||
@ -15,8 +21,7 @@ from compute.exceptions import (
|
||||
)
|
||||
from compute.instance import GuestAgent
|
||||
from compute.session import Session
|
||||
|
||||
from ._create import _create_instance
|
||||
from compute.utils import ids
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
@ -37,41 +42,41 @@ class Table:
|
||||
"""Initialise Table."""
|
||||
self.whitespace = whitespace or '\t'
|
||||
self.header = []
|
||||
self._rows = []
|
||||
self._table = ''
|
||||
self.rows = []
|
||||
self.table = ''
|
||||
|
||||
def row(self, row: list) -> None:
|
||||
def add_row(self, row: list) -> None:
|
||||
"""Add table row."""
|
||||
self._rows.append([str(col) for col in row])
|
||||
self.rows.append([str(col) for col in row])
|
||||
|
||||
def rows(self, rows: list[list]) -> None:
|
||||
def add_rows(self, rows: list[list]) -> None:
|
||||
"""Add multiple rows."""
|
||||
for row in rows:
|
||||
self.row(row)
|
||||
self.add_row(row)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Build table and return."""
|
||||
widths = [max(map(len, col)) for col in zip(*self._rows, strict=True)]
|
||||
self._rows.insert(0, [str(h).upper() for h in self.header])
|
||||
for row in self._rows:
|
||||
self._table += self.whitespace.join(
|
||||
widths = [max(map(len, col)) for col in zip(*self.rows, strict=True)]
|
||||
self.rows.insert(0, [str(h).upper() for h in self.header])
|
||||
for row in self.rows:
|
||||
self.table += self.whitespace.join(
|
||||
(
|
||||
val.ljust(width)
|
||||
for val, width in zip(row, widths, strict=True)
|
||||
)
|
||||
)
|
||||
self._table += '\n'
|
||||
return self._table.strip()
|
||||
self.table += '\n'
|
||||
return self.table.strip()
|
||||
|
||||
|
||||
def _list_instances(session: Session) -> None:
|
||||
table = Table()
|
||||
table.header = ['NAME', 'STATE']
|
||||
for instance in session.list_instances():
|
||||
table.row(
|
||||
table.add_row(
|
||||
[
|
||||
instance.name,
|
||||
instance.status,
|
||||
instance.get_status(),
|
||||
]
|
||||
)
|
||||
print(table)
|
||||
@ -113,11 +118,93 @@ def _exec_guest_agent_command(
|
||||
sys.exit(output.exitcode)
|
||||
|
||||
|
||||
class _NotPresent:
|
||||
"""
|
||||
Type for representing non-existent dictionary keys.
|
||||
|
||||
See :class:`_FillableDict`.
|
||||
"""
|
||||
|
||||
|
||||
class _FillableDict(UserDict):
|
||||
"""Use :method:`fill` to add key if not present."""
|
||||
|
||||
def __init__(self, data: dict):
|
||||
self.data = data
|
||||
|
||||
def fill(self, key: str, value: Any) -> None: # noqa: ANN401
|
||||
if self.data.get(key, _NotPresent) is _NotPresent:
|
||||
self.data[key] = value
|
||||
|
||||
|
||||
def _merge_dicts(a: dict, b: dict, path: list[str] | None = None) -> dict:
|
||||
"""Merge `b` into `a`. Return modified `a`."""
|
||||
if path is None:
|
||||
path = []
|
||||
for key in b:
|
||||
if key in a:
|
||||
if isinstance(a[key], dict) and isinstance(b[key], dict):
|
||||
_merge_dicts(a[key], b[key], [path + str(key)])
|
||||
elif a[key] == b[key]:
|
||||
pass # same leaf value
|
||||
else:
|
||||
a[key] = b[key] # replace existing key's values
|
||||
else:
|
||||
a[key] = b[key]
|
||||
return a
|
||||
|
||||
|
||||
def _create_instance(session: Session, file: io.TextIOWrapper) -> None:
|
||||
try:
|
||||
data = _FillableDict(yaml.load(file.read(), Loader=yaml.SafeLoader))
|
||||
log.debug('Read from file: %s', data)
|
||||
except yaml.YAMLError as e:
|
||||
sys.exit(f'error: cannot parse YAML: {e}')
|
||||
|
||||
capabilities = session.get_capabilities()
|
||||
node_info = session.get_node_info()
|
||||
|
||||
data.fill('name', uuid4().hex)
|
||||
data.fill('title', None)
|
||||
data.fill('description', None)
|
||||
data.fill('arch', capabilities.arch)
|
||||
data.fill('machine', capabilities.machine)
|
||||
data.fill('emulator', capabilities.emulator)
|
||||
data.fill('max_vcpus', node_info.cpus)
|
||||
data.fill('max_memory', node_info.memory)
|
||||
data.fill('cpu', {})
|
||||
cpu = {
|
||||
'emulation_mode': 'host-passthrough',
|
||||
'model': None,
|
||||
'vendor': None,
|
||||
'topology': None,
|
||||
'features': None,
|
||||
}
|
||||
data['cpu'] = _merge_dicts(data['cpu'], cpu)
|
||||
data.fill(
|
||||
'network_interfaces',
|
||||
[{'source': 'default', 'mac': ids.random_mac()}],
|
||||
)
|
||||
data.fill('boot', {'order': ['cdrom', 'hd']})
|
||||
|
||||
try:
|
||||
log.debug('Input data: %s', data)
|
||||
session.create_instance(**data)
|
||||
except ValidationError as e:
|
||||
for error in e.errors():
|
||||
fields = '.'.join([str(lc) for lc in error['loc']])
|
||||
print(
|
||||
f"validation error: {fields}: {error['msg']}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit()
|
||||
|
||||
|
||||
def main(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Perform actions."""
|
||||
match args.command:
|
||||
case 'create':
|
||||
_create_instance(session, args)
|
||||
_create_instance(session, args.file)
|
||||
case 'exec':
|
||||
_exec_guest_agent_command(session, args)
|
||||
case 'ls':
|
||||
@ -179,30 +266,13 @@ def cli() -> None: # noqa: PLR0915
|
||||
subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
|
||||
|
||||
# create command
|
||||
create = subparsers.add_parser('create', help='create compute instance')
|
||||
create.add_argument('image', nargs='?')
|
||||
create.add_argument('--name', help='instance name, used as ID')
|
||||
create.add_argument('--title', help='human-understandable instance title')
|
||||
create.add_argument('--desc', default='', help='instance description')
|
||||
create.add_argument('--memory', type=int, help='memory in MiB')
|
||||
create.add_argument('--max-memory', type=int, help='max memory in MiB')
|
||||
create.add_argument('--vcpus', type=int)
|
||||
create.add_argument('--max-vcpus', type=int)
|
||||
create.add_argument('--cpu-vendor')
|
||||
create.add_argument('--cpu-model')
|
||||
create.add_argument(
|
||||
'--cpu-emulation-mode',
|
||||
choices=['host-passthrough', 'host-model', 'custom'],
|
||||
default='host-passthrough',
|
||||
create = subparsers.add_parser(
|
||||
'create', help='create new instance from YAML config file'
|
||||
)
|
||||
create.add_argument(
|
||||
'file',
|
||||
type=argparse.FileType('r', encoding='UTF-8'),
|
||||
)
|
||||
create.add_argument('--cpu-features')
|
||||
create.add_argument('--cpu-topology')
|
||||
create.add_argument('--mahine')
|
||||
create.add_argument('--emulator')
|
||||
create.add_argument('--arch')
|
||||
create.add_argument('--boot-order')
|
||||
create.add_argument('--volume')
|
||||
create.add_argument('-f', '--file', help='create instance from YAML')
|
||||
|
||||
# exec subcommand
|
||||
execute = subparsers.add_parser(
|
||||
@ -303,14 +373,17 @@ def cli() -> None: # noqa: PLR0915
|
||||
if log_level in log_levels:
|
||||
logging.basicConfig(level=log_levels[log_level])
|
||||
|
||||
log.debug('CLI started with args: %s', args)
|
||||
# Perform actions
|
||||
try:
|
||||
with Session(args.connect) as session:
|
||||
main(session, args)
|
||||
except ComputeServiceError as e:
|
||||
sys.exit(f'error: {e}')
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
except KeyboardInterrupt:
|
||||
sys.exit()
|
||||
except SystemExit as e:
|
||||
sys.exit(e)
|
||||
except Exception as e: # noqa: BLE001
|
||||
sys.exit(f'unexpected error {type(e)}: {e}')
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Compute Service exceptions."""
|
||||
"""Exceptions."""
|
||||
|
||||
|
||||
class ComputeServiceError(Exception):
|
||||
|
@ -1,4 +1,4 @@
|
||||
"""Manage QEMU guest agent."""
|
||||
"""Interacting with the QEMU Guest Agent."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
@ -19,9 +19,6 @@ from compute.exceptions import (
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
QEMU_TIMEOUT = 60
|
||||
POLL_INTERVAL = 0.3
|
||||
|
||||
|
||||
class GuestExecOutput(NamedTuple):
|
||||
"""QEMU guest-exec command output."""
|
||||
@ -35,7 +32,7 @@ class GuestExecOutput(NamedTuple):
|
||||
class GuestAgent:
|
||||
"""Class for interacting with QEMU guest agent."""
|
||||
|
||||
def __init__(self, domain: libvirt.virDomain, timeout: int | None = None):
|
||||
def __init__(self, domain: libvirt.virDomain, timeout: int = 60):
|
||||
"""
|
||||
Initialise GuestAgent.
|
||||
|
||||
@ -43,7 +40,7 @@ class GuestAgent:
|
||||
:param timeout: QEMU timeout
|
||||
"""
|
||||
self.domain = domain
|
||||
self.timeout = timeout or QEMU_TIMEOUT
|
||||
self.timeout = timeout
|
||||
self.flags = libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
|
||||
self.last_pid = None
|
||||
|
||||
@ -65,9 +62,6 @@ class GuestAgent:
|
||||
return json.loads(output)
|
||||
except libvirt.libvirtError as e:
|
||||
if e.get_error_code() == libvirt.VIR_ERR_AGENT_UNRESPONSIVE:
|
||||
log.exception(
|
||||
'Guest agent is unavailable on instanse=%s', self.name
|
||||
)
|
||||
raise GuestAgentUnavailableError(e) from e
|
||||
raise GuestAgentError(e) from e
|
||||
|
||||
@ -95,9 +89,7 @@ class GuestAgent:
|
||||
|
||||
def raise_for_commands(self, commands: list[str]) -> None:
|
||||
"""
|
||||
Check QEMU guest agent command availability.
|
||||
|
||||
Raise exception if command is not available.
|
||||
Raise exception if QEMU GA command is not available.
|
||||
|
||||
:param commands: List of required commands
|
||||
:raise: GuestAgentCommandNotSupportedError
|
||||
@ -164,12 +156,15 @@ class GuestAgent:
|
||||
stderr = b64decode(stderr or '').decode('utf-8')
|
||||
return GuestExecOutput(exited, exitcode, stdout, stderr)
|
||||
|
||||
def guest_exec_status(self, pid: int, *, poll: bool = False) -> dict:
|
||||
def guest_exec_status(
|
||||
self, pid: int, *, poll: bool = False, poll_interval: float = 0.3
|
||||
) -> dict:
|
||||
"""
|
||||
Execute guest-exec-status and return output.
|
||||
|
||||
:param pid: PID in guest
|
||||
:param poll: If True poll command status with POLL_INTERVAL
|
||||
:param pid: PID in guest.
|
||||
:param poll: If True poll command status.
|
||||
:param poll_interval: Time between attempts to obtain command status.
|
||||
:return: Command output
|
||||
:rtype: dict
|
||||
"""
|
||||
@ -185,7 +180,7 @@ class GuestAgent:
|
||||
command_status = self.execute(command)
|
||||
if command_status['return']['exited']:
|
||||
break
|
||||
sleep(POLL_INTERVAL)
|
||||
sleep(poll_interval)
|
||||
now = time()
|
||||
if now - start_time > self.timeout:
|
||||
raise GuestAgentTimeoutExceededError(self.timeout)
|
||||
|
@ -1,9 +1,9 @@
|
||||
"""Manage compute instances."""
|
||||
|
||||
__all__ = ['Instance', 'InstanceConfig']
|
||||
__all__ = ['Instance', 'InstanceConfig', 'InstanceInfo']
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import NamedTuple
|
||||
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
@ -16,14 +16,19 @@ from compute.exceptions import (
|
||||
from compute.utils import units
|
||||
|
||||
from .guest_agent import GuestAgent
|
||||
from .schemas import CPUSchema, InstanceSchema, NetworkInterfaceSchema
|
||||
from .schemas import (
|
||||
CPUEmulationMode,
|
||||
CPUSchema,
|
||||
InstanceSchema,
|
||||
NetworkInterfaceSchema,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstanceConfig:
|
||||
"""Compute instance description for libvirt."""
|
||||
"""Compute instance config builder."""
|
||||
|
||||
def __init__(self, schema: InstanceSchema):
|
||||
"""
|
||||
@ -46,21 +51,33 @@ class InstanceConfig:
|
||||
self.network_interfaces = schema.network_interfaces
|
||||
|
||||
def _gen_cpu_xml(self, cpu: CPUSchema) -> etree.Element:
|
||||
xml = E.cpu(match='exact', mode=cpu.emulation_mode)
|
||||
xml.append(E.model(cpu.model, fallback='forbid'))
|
||||
xml.append(E.vendor(cpu.vendor))
|
||||
xml.append(
|
||||
E.topology(
|
||||
sockets=str(cpu.topology.sockets),
|
||||
dies=str(cpu.topology.dies),
|
||||
cores=str(cpu.topology.cores),
|
||||
threads=str(cpu.topology.threads),
|
||||
options = {
|
||||
'mode': cpu.emulation_mode,
|
||||
'match': 'exact',
|
||||
'check': 'partial',
|
||||
}
|
||||
if cpu.emulation_mode == CPUEmulationMode.HOST_PASSTHROUGH:
|
||||
options['check'] = 'none'
|
||||
options['migratable'] = 'on'
|
||||
xml = E.cpu(**options)
|
||||
if cpu.model:
|
||||
xml.append(E.model(cpu.model, fallback='forbid'))
|
||||
if cpu.vendor:
|
||||
xml.append(E.vendor(cpu.vendor))
|
||||
if cpu.topology:
|
||||
xml.append(
|
||||
E.topology(
|
||||
sockets=str(cpu.topology.sockets),
|
||||
dies=str(cpu.topology.dies),
|
||||
cores=str(cpu.topology.cores),
|
||||
threads=str(cpu.topology.threads),
|
||||
)
|
||||
)
|
||||
)
|
||||
for feature in cpu.features.require:
|
||||
xml.append(E.feature(policy='require', name=feature))
|
||||
for feature in cpu.features.disable:
|
||||
xml.append(E.feature(policy='disable', name=feature))
|
||||
if cpu.features:
|
||||
for feature in cpu.features.require:
|
||||
xml.append(E.feature(policy='require', name=feature))
|
||||
for feature in cpu.features.disable:
|
||||
xml.append(E.feature(policy='disable', name=feature))
|
||||
return xml
|
||||
|
||||
def _gen_vcpus_xml(self, vcpus: int, max_vcpus: int) -> etree.Element:
|
||||
@ -89,15 +106,15 @@ class InstanceConfig:
|
||||
|
||||
def to_xml(self) -> str:
|
||||
"""Return XML config for libvirt."""
|
||||
xml = E.domain(
|
||||
E.name(self.name),
|
||||
E.title(self.title),
|
||||
E.description(self.description),
|
||||
E.metadata(),
|
||||
E.memory(str(self.memory * 1024), unit='KiB'),
|
||||
E.currentMemory(str(self.memory * 1024), unit='KiB'),
|
||||
type='kvm',
|
||||
)
|
||||
xml = E.domain(type='kvm')
|
||||
xml.append(E.name(self.name))
|
||||
if self.title:
|
||||
xml.append(E.title(self.title))
|
||||
if self.description:
|
||||
xml.append(E.description(self.description))
|
||||
xml.append(E.metadata())
|
||||
xml.append(E.memory(str(self.max_memory * 1024), unit='KiB'))
|
||||
xml.append(E.currentMemory(str(self.memory * 1024), unit='KiB'))
|
||||
xml.append(
|
||||
E.vcpu(
|
||||
str(self.max_vcpus),
|
||||
@ -148,8 +165,14 @@ class InstanceConfig:
|
||||
return etree.tostring(xml, encoding='unicode', pretty_print=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceInfo:
|
||||
class InstanceInfo(NamedTuple):
|
||||
"""
|
||||
Store compute instance info.
|
||||
|
||||
Reference:
|
||||
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainInfo
|
||||
"""
|
||||
|
||||
state: str
|
||||
max_memory: int
|
||||
memory: int
|
||||
@ -193,13 +216,8 @@ class Instance:
|
||||
}
|
||||
return states[state]
|
||||
|
||||
@property
|
||||
def info(self) -> InstanceInfo:
|
||||
"""
|
||||
Return instance info.
|
||||
|
||||
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainInfo
|
||||
"""
|
||||
def get_info(self) -> InstanceInfo:
|
||||
"""Return instance info."""
|
||||
_info = self.domain.info()
|
||||
return InstanceInfo(
|
||||
state=self._expand_instance_state(_info[0]),
|
||||
@ -209,8 +227,7 @@ class Instance:
|
||||
cputime=_info[4],
|
||||
)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
def get_status(self) -> str:
|
||||
"""
|
||||
Return instance state: 'running', 'shutoff', etc.
|
||||
|
||||
@ -225,7 +242,6 @@ class Instance:
|
||||
) from e
|
||||
return self._expand_instance_state(state)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Return True if instance is running, else return False."""
|
||||
if self.domain.isActive() != 1:
|
||||
@ -233,7 +249,6 @@ class Instance:
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_autostart(self) -> bool:
|
||||
"""Return True if instance autostart is enabled, else return False."""
|
||||
try:
|
||||
@ -244,10 +259,18 @@ class Instance:
|
||||
f'instance={self.name}: {e}'
|
||||
) from e
|
||||
|
||||
def get_max_memory(self) -> int:
|
||||
"""Maximum memory value for domain in KiB."""
|
||||
return self.domain.maxMemory()
|
||||
|
||||
def get_max_vcpus(self) -> int:
|
||||
"""Maximum vCPUs number for domain."""
|
||||
return self.domain.maxVcpus()
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start defined instance."""
|
||||
log.info('Starting instnce=%s', self.name)
|
||||
if self.is_running:
|
||||
if self.is_running():
|
||||
log.warning(
|
||||
'Already started, nothing to do instance=%s', self.name
|
||||
)
|
||||
@ -311,6 +334,15 @@ class Instance:
|
||||
f'Cannot shutdown instance={self.name} ' f'{method=}: {e}'
|
||||
) from e
|
||||
|
||||
def reboot(self) -> None:
|
||||
"""Send ACPI signal to guest OS to reboot. OS may ignore this."""
|
||||
try:
|
||||
self.domain.reboot()
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
f'Cannot reboot instance={self.name}: {e}'
|
||||
) from e
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset instance.
|
||||
@ -331,14 +363,19 @@ class Instance:
|
||||
f'Cannot reset instance={self.name}: {e}'
|
||||
) from e
|
||||
|
||||
def reboot(self) -> None:
|
||||
"""Send ACPI signal to guest OS to reboot. OS may ignore this."""
|
||||
try:
|
||||
self.domain.reboot()
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
f'Cannot reboot instance={self.name}: {e}'
|
||||
) from e
|
||||
def power_reset(self) -> None:
|
||||
"""
|
||||
Shutdown instance and start.
|
||||
|
||||
By analogy with real hardware, this is a normal server shutdown,
|
||||
and then turning off from the power supply and turning it on again.
|
||||
|
||||
This method is applicable in cases where there has been a
|
||||
configuration change in libvirt and you need to restart the
|
||||
instance to apply the new configuration.
|
||||
"""
|
||||
self.shutdown(method='NORMAL')
|
||||
self.start()
|
||||
|
||||
def set_autostart(self, *, enabled: bool) -> None:
|
||||
"""
|
||||
@ -383,7 +420,7 @@ class Instance:
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
self.domain.setVcpusFlags(nvcpus, flags=flags)
|
||||
if live is True:
|
||||
if not self.is_running:
|
||||
if not self.is_running():
|
||||
log.warning(
|
||||
'Instance is not running, changes applied in '
|
||||
'instance config.'
|
||||
@ -453,7 +490,7 @@ class Instance:
|
||||
:param device: Object with device description e.g. DiskConfig
|
||||
:param live: Affect a running instance
|
||||
"""
|
||||
if live and self.is_running:
|
||||
if live and self.is_running():
|
||||
flags = (
|
||||
libvirt.VIR_DOMAIN_AFFECT_LIVE
|
||||
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
@ -471,7 +508,7 @@ class Instance:
|
||||
:param device: Object with device description e.g. DiskConfig
|
||||
:param live: Affect a running instance
|
||||
"""
|
||||
if live and self.is_running:
|
||||
if live and self.is_running():
|
||||
flags = (
|
||||
libvirt.VIR_DOMAIN_AFFECT_LIVE
|
||||
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
|
@ -4,11 +4,20 @@ import re
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
from pydantic import BaseModel, Extra, validator
|
||||
|
||||
from compute.utils.units import DataUnit
|
||||
|
||||
|
||||
class EntityModel(BaseModel):
|
||||
"""Basic entity model."""
|
||||
|
||||
class Config:
|
||||
"""Do not allow extra fields."""
|
||||
|
||||
extra = Extra.forbid
|
||||
|
||||
|
||||
class CPUEmulationMode(StrEnum):
|
||||
"""CPU emulation mode enumerated."""
|
||||
|
||||
@ -18,7 +27,7 @@ class CPUEmulationMode(StrEnum):
|
||||
MAXIMUM = 'maximum'
|
||||
|
||||
|
||||
class CPUTopologySchema(BaseModel):
|
||||
class CPUTopologySchema(EntityModel):
|
||||
"""CPU topology model."""
|
||||
|
||||
sockets: int
|
||||
@ -27,67 +36,66 @@ class CPUTopologySchema(BaseModel):
|
||||
dies: int = 1
|
||||
|
||||
|
||||
class CPUFeaturesSchema(BaseModel):
|
||||
class CPUFeaturesSchema(EntityModel):
|
||||
"""CPU features model."""
|
||||
|
||||
require: list[str]
|
||||
disable: list[str]
|
||||
|
||||
|
||||
class CPUSchema(BaseModel):
|
||||
class CPUSchema(EntityModel):
|
||||
"""CPU model."""
|
||||
|
||||
emulation_mode: CPUEmulationMode
|
||||
model: str
|
||||
vendor: str
|
||||
topology: CPUTopologySchema
|
||||
features: CPUFeaturesSchema
|
||||
model: str | None
|
||||
vendor: str | None
|
||||
topology: CPUTopologySchema | None
|
||||
features: CPUFeaturesSchema | None
|
||||
|
||||
|
||||
class VolumeType(StrEnum):
|
||||
"""Storage volume types enumeration."""
|
||||
|
||||
FILE = 'file'
|
||||
NETWORK = 'network'
|
||||
|
||||
|
||||
class VolumeCapacitySchema(BaseModel):
|
||||
class VolumeCapacitySchema(EntityModel):
|
||||
"""Storage volume capacity field model."""
|
||||
|
||||
value: int
|
||||
unit: DataUnit
|
||||
|
||||
|
||||
class VolumeSchema(BaseModel):
|
||||
class VolumeSchema(EntityModel):
|
||||
"""Storage volume model."""
|
||||
|
||||
type: VolumeType # noqa: A003
|
||||
source: Path
|
||||
target: str
|
||||
capacity: VolumeCapacitySchema
|
||||
readonly: bool = False
|
||||
source: str | None = None
|
||||
is_readonly: bool = False
|
||||
is_system: bool = False
|
||||
|
||||
|
||||
class NetworkInterfaceSchema(BaseModel):
|
||||
class NetworkInterfaceSchema(EntityModel):
|
||||
"""Network inerface model."""
|
||||
|
||||
source: str
|
||||
mac: str
|
||||
|
||||
|
||||
class BootOptionsSchema(BaseModel):
|
||||
class BootOptionsSchema(EntityModel):
|
||||
"""Instance boot settings."""
|
||||
|
||||
order: tuple
|
||||
|
||||
|
||||
class InstanceSchema(BaseModel):
|
||||
class InstanceSchema(EntityModel):
|
||||
"""Compute instance model."""
|
||||
|
||||
name: str
|
||||
title: str
|
||||
description: str
|
||||
title: str | None
|
||||
description: str | None
|
||||
memory: int
|
||||
max_memory: int
|
||||
vcpus: int
|
||||
@ -96,10 +104,10 @@ class InstanceSchema(BaseModel):
|
||||
machine: str
|
||||
emulator: Path
|
||||
arch: str
|
||||
image: str
|
||||
boot: BootOptionsSchema
|
||||
volumes: list[VolumeSchema]
|
||||
network_interfaces: list[NetworkInterfaceSchema]
|
||||
image: str | None = None
|
||||
|
||||
@validator('name')
|
||||
def _check_name(cls, value: str) -> str: # noqa: N805
|
||||
@ -111,12 +119,28 @@ class InstanceSchema(BaseModel):
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@validator('volumes')
|
||||
def _check_volumes(cls, value: list) -> list: # noqa: N805
|
||||
if len([v for v in value if v.is_system is True]) != 1:
|
||||
msg = 'Volumes list must contain one system volume'
|
||||
@validator('cpu')
|
||||
def _check_topology(cls, cpu: int, values: dict) -> CPUSchema: # noqa: N805
|
||||
topo = cpu.topology
|
||||
max_vcpus = values['max_vcpus']
|
||||
if topo and topo.sockets * topo.cores * topo.threads != max_vcpus:
|
||||
msg = f'CPU topology does not match with {max_vcpus=}'
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
return cpu
|
||||
|
||||
@validator('volumes')
|
||||
def _check_volumes(cls, volumes: list) -> list: # noqa: N805
|
||||
if len([v for v in volumes if v.is_system is True]) != 1:
|
||||
msg = 'volumes list must contain one system volume'
|
||||
raise ValueError(msg)
|
||||
vol_with_source = 0
|
||||
for vol in volumes:
|
||||
if vol.is_system is True and vol.is_readonly is True:
|
||||
msg = 'volume marked as system cannot be readonly'
|
||||
raise ValueError(msg)
|
||||
if vol.source is not None:
|
||||
vol_with_source += 1
|
||||
return volumes
|
||||
|
||||
@validator('network_interfaces')
|
||||
def _check_network_interfaces(cls, value: list) -> list: # noqa: N805
|
||||
|
@ -26,6 +26,25 @@ class Capabilities(NamedTuple):
|
||||
virt: str
|
||||
emulator: str
|
||||
machine: str
|
||||
max_vcpus: int
|
||||
|
||||
|
||||
class NodeInfo(NamedTuple):
|
||||
"""
|
||||
Store compute node info.
|
||||
|
||||
See https://libvirt.org/html/libvirt-libvirt-host.html#virNodeInfo
|
||||
NOTE: memory unit in libvirt docs is wrong! Actual unit is MiB.
|
||||
"""
|
||||
|
||||
arch: str
|
||||
memory: int
|
||||
cpus: int
|
||||
mhz: int
|
||||
nodes: int
|
||||
sockets: int
|
||||
cores: int
|
||||
threads: int
|
||||
|
||||
|
||||
class Session(AbstractContextManager):
|
||||
@ -68,7 +87,21 @@ class Session(AbstractContextManager):
|
||||
"""Close connection to libvirt daemon."""
|
||||
self.connection.close()
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
def get_node_info(self) -> NodeInfo:
|
||||
"""Return information about compute node."""
|
||||
info = self.connection.getInfo()
|
||||
return NodeInfo(
|
||||
arch=info[0],
|
||||
memory=info[1],
|
||||
cpus=info[2],
|
||||
mhz=info[3],
|
||||
nodes=info[4],
|
||||
sockets=info[5],
|
||||
cores=info[6],
|
||||
threads=info[7],
|
||||
)
|
||||
|
||||
def get_capabilities(self) -> Capabilities:
|
||||
"""Return capabilities e.g. arch, virt, emulator, etc."""
|
||||
prefix = '/domainCapabilities'
|
||||
caps = etree.fromstring(self.connection.getDomainCapabilities()) # noqa: S320
|
||||
@ -77,6 +110,7 @@ class Session(AbstractContextManager):
|
||||
virt=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]),
|
||||
)
|
||||
|
||||
def create_instance(self, **kwargs: Any) -> Instance:
|
||||
@ -87,14 +121,35 @@ class Session(AbstractContextManager):
|
||||
:type name: str
|
||||
:param title: Instance title for humans.
|
||||
:type title: str
|
||||
:param description: Some information about instance
|
||||
:param description: Some information about instance.
|
||||
:type description: str
|
||||
:param memory: Memory in MiB.
|
||||
:type memory: int
|
||||
:param max_memory: Maximum memory in MiB.
|
||||
:type max_memory: int
|
||||
:param vcpus: Number of vCPUs.
|
||||
:type vcpus: int
|
||||
:param max_vcpus: Maximum vCPUs.
|
||||
:type max_vcpus: int
|
||||
:param cpu: CPU configuration. See :class:`CPUSchema` for info.
|
||||
:type cpu: dict
|
||||
:param machine: QEMU emulated machine.
|
||||
:type machine: str
|
||||
:param emulator: Path to emulator.
|
||||
:type emulator: str
|
||||
:param arch: CPU architecture to virtualization.
|
||||
:type arch: str
|
||||
:param boot: Boot settings. See :class:`BootOptionsSchema`.
|
||||
:type boot: dict
|
||||
:param image: Source disk image name for system disk.
|
||||
:type image: str
|
||||
:param volumes: List of storage volume configs. For more info
|
||||
see :class:`VolumeSchema`.
|
||||
:type volumes: list[dict]
|
||||
:param network_interfaces: List of virtual network interfaces
|
||||
configs. See :class:`NetworkInterfaceSchema` for more info.
|
||||
:type network_interfaces: list[dict]
|
||||
"""
|
||||
# TODO @ge: create instances in transaction
|
||||
data = InstanceSchema(**kwargs)
|
||||
config = InstanceConfig(data)
|
||||
log.info('Define XML...')
|
||||
@ -113,19 +168,17 @@ class Session(AbstractContextManager):
|
||||
log.info('Connecting to volumes pool...')
|
||||
volumes_pool = self.get_storage_pool(self.VOLUMES_POOL)
|
||||
log.info('Building volume configuration...')
|
||||
# if not volume.source:
|
||||
# В случае если пользователь передаёт source для волюма, следует
|
||||
# в либвирте делать поиск волюма по пути, а не по имени
|
||||
# gen_vol_name
|
||||
# TODO @ge: come up with something else
|
||||
vol_name = f'{config.name}-{volume.target}-{uuid4()}.qcow2'
|
||||
if not volume.source:
|
||||
vol_name = f'{config.name}-{volume.target}-{uuid4()}.qcow2'
|
||||
else:
|
||||
vol_name = volume.source
|
||||
vol_conf = VolumeConfig(
|
||||
name=vol_name,
|
||||
path=str(volumes_pool.path.joinpath(vol_name)),
|
||||
capacity=capacity,
|
||||
)
|
||||
log.info('Volume configuration is:\n %s', vol_conf.to_xml())
|
||||
if volume.is_system is True:
|
||||
if volume.is_system is True and data.image:
|
||||
log.info(
|
||||
"Volume is marked as 'system', start cloning image..."
|
||||
)
|
||||
|
@ -15,8 +15,8 @@ from .volume import Volume, VolumeConfig
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StoragePoolUsage(NamedTuple):
|
||||
"""Storage pool usage info schema."""
|
||||
class StoragePoolUsageInfo(NamedTuple):
|
||||
"""Storage pool usage info."""
|
||||
|
||||
capacity: int
|
||||
allocation: int
|
||||
@ -30,17 +30,17 @@ class StoragePool:
|
||||
"""Initislise StoragePool."""
|
||||
self.pool = pool
|
||||
self.name = pool.name()
|
||||
self.path = self._get_path()
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
def _get_path(self) -> Path:
|
||||
"""Return storage pool path."""
|
||||
xml = etree.fromstring(self.pool.XMLDesc()) # noqa: S320
|
||||
return Path(xml.xpath('/pool/target/path/text()')[0])
|
||||
|
||||
def usage(self) -> StoragePoolUsage:
|
||||
def get_usage_info(self) -> StoragePoolUsageInfo:
|
||||
"""Return info about storage pool usage."""
|
||||
xml = etree.fromstring(self.pool.XMLDesc()) # noqa: S320
|
||||
return StoragePoolUsage(
|
||||
return StoragePoolUsageInfo(
|
||||
capacity=int(xml.xpath('/pool/capacity/text()')[0]),
|
||||
allocation=int(xml.xpath('/pool/allocation/text()')[0]),
|
||||
available=int(xml.xpath('/pool/available/text()')[0]),
|
||||
@ -58,7 +58,7 @@ class StoragePool:
|
||||
def create_volume(self, vol_conf: VolumeConfig) -> Volume:
|
||||
"""Create storage volume and return Volume instance."""
|
||||
log.info(
|
||||
'Create storage volume vol=%s in pool=%s', vol_conf.name, self.pool
|
||||
'Create storage volume vol=%s in pool=%s', vol_conf.name, self.name
|
||||
)
|
||||
vol = self.pool.createXML(
|
||||
vol_conf.to_xml(),
|
||||
|
@ -87,11 +87,7 @@ class Volume:
|
||||
self.pool_name = pool.name()
|
||||
self.vol = vol
|
||||
self.name = vol.name()
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Return path to volume."""
|
||||
return Path(self.vol.path())
|
||||
self.path = Path(vol.path())
|
||||
|
||||
def dump_xml(self) -> str:
|
||||
"""Return volume XML description as string."""
|
||||
|
Reference in New Issue
Block a user