various improvements

This commit is contained in:
ge
2023-11-09 01:17:50 +03:00
parent 8e7f185fc6
commit 1dd3e9a720
28 changed files with 557 additions and 219 deletions

View File

@ -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)

View File

@ -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

View File

@ -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