This commit is contained in:
ge
2023-08-31 20:37:41 +03:00
parent e8133af392
commit 118c4c376a
18 changed files with 409 additions and 437 deletions

View File

@ -1,5 +1,4 @@
from .exceptions import *
from .guest_agent import QemuAgent
from .guest_agent import GuestAgent
from .installer import CPUMode, CPUTopology, VirtualMachineInstaller
from .virtual_machine import VirtualMachine
from .installer import VirtualMachineInstaller
from .hardware import vCPUMode, vCPUTopology

View File

@ -17,7 +17,6 @@ class VirtualMachineBase:
raise VMError(f'Cannot get domain name: {err}') from err
def _get_domain_info(self):
# https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainInfo
try:
info = self.domain.info()
return {

View File

@ -1,4 +1,4 @@
class QemuAgentError(Exception):
class GuestAgentError(Exception):
"""Mostly QEMU Guest Agent is not responding."""

View File

@ -7,17 +7,16 @@ import libvirt
import libvirt_qemu
from .base import VirtualMachineBase
from .exceptions import QemuAgentError
from .exceptions import GuestAgentError
logger = logging.getLogger(__name__)
# Note that if no QEMU_TIMEOUT libvirt cannot connect to agent
QEMU_TIMEOUT = 60 # in seconds
POLL_INTERVAL = 0.3 # also in seconds
class QemuAgent(VirtualMachineBase):
class GuestAgent(VirtualMachineBase):
"""
Interacting with QEMU guest agent. Methods:
@ -47,7 +46,7 @@ class QemuAgent(VirtualMachineBase):
Execute command on guest and return output if `capture_output` is True.
See https://wiki.qemu.org/Documentation/QMP for QEMU commands reference.
If `wait` is True poll guest command output with POLL_INTERVAL. Raise
QemuAgentError on `timeout` reached (in seconds).
GuestAgentError on `timeout` reached (in seconds).
Return values:
tuple(
exited: bool | None,
@ -121,9 +120,9 @@ class QemuAgent(VirtualMachineBase):
self.flags,
)
except libvirt.libvirtError as err:
raise QemuAgentError(
f'Cannot execute command on vm={self.domain_name}: {err}'
) from err
raise GuestAgentError(
f'Cannot execute command on vm={self.domain_name}: {err}'
) from err
def _get_cmd_result(
self, pid: int, decode_output: bool = False, wait: bool = True,
@ -145,7 +144,7 @@ class QemuAgent(VirtualMachineBase):
sleep(POLL_INTERVAL)
now = time()
if now - start_time > timeout:
raise QemuAgentError(
raise GuestAgentError(
f'Polling command pid={pid} on vm={self.domain_name} '
f'took longer than {timeout} seconds.'
)

View File

@ -1,81 +0,0 @@
import textwrap
from enum import Enum
from collections import UserDict
import libvirt
from lxml.etree import SubElement, fromstring, tostring
class Boot(Enum):
BIOS = 'bios'
UEFI = 'uefi'
class vCPUMode(Enum):
HOST_MODEL = 'host-model'
HOST_PASSTHROUGTH = 'host-passthrougth'
CUSTOM = 'custom'
MAXIMUM = 'maximum'
class DomainCapabilities:
def __init__(self, session: libvirt.virConnect):
self.session = session
self.domcaps = fromstring(
self.session.getDomainCapabilities())
@property
def arch(self):
return self.domcaps.xpath('/domainCapabilities/arch')[0].text
@property
def virttype(self):
return self.domcaps.xpath('/domainCapabilities/domain')[0].text
@property
def emulator(self):
return self.domcaps.xpath('/domainCapabilities/path')[0].text
@property
def machine(self):
return self.domcaps.xpath('/domainCapabilities/machine')[0].text
def best_cpu(self, mode: vCPUMode) -> str:
"""
See https://libvirt.org/html/libvirt-libvirt-host.html
#virConnectBaselineHypervisorCPU
"""
cpus = self.domcaps.xpath(
f'/domainCapabilities/cpu/mode[@name="{mode}"]')[0]
cpus.tag = 'cpu'
for attr in cpus.attrib.keys():
del cpus.attrib[attr]
arch = SubElement(cpus, 'arch')
arch.text = self.arch
xmlcpus = tostring(cpus, encoding='unicode', pretty_print=True)
xml = self.session.baselineHypervisorCPU(self.emulator,
self.arch, self.machine, self.virttype, [xmlcpus])
return textwrap.indent(xml, ' ' * 2)
class vCPUTopology(UserDict):
"""
CPU topology schema ``{'sockets': 1, 'cores': 4, 'threads': 1}``::
<topology sockets='1' dies='1' cores='4' threads='1'/>
"""
def __init__(self, topology: dict):
super().__init__(self._validate(topology))
def _validate(self, topology: dict):
if isinstance(topology, dict):
if ['sockets', 'cores', 'threads'] != list(topology.keys()):
raise ValueError("Topology must have 'sockets', 'cores' "
"and 'threads' keys.")
for key in topology.keys():
if not isinstance(topology[key], int):
raise TypeError(f"Key '{key}' must be 'int'")
return topology
raise TypeError("Topology must be a 'dict'")

View File

@ -1,77 +1,159 @@
import re
import textwrap
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from uuid import UUID
import libvirt
from lxml.etree import SubElement, fromstring, tostring
from ..utils.xml import Constructor
from ..utils.mac import random_mac
from .hardware import DomainCapabilities, vCPUMode, vCPUTopology, Boot
from ..utils import mac, xml
class vCPUInfo:
pass
class CPUMode(Enum):
HOST_MODEL = 'host-model'
HOST_PASSTHROUGH = 'host-passthrough'
CUSTOM = 'custom'
MAXIMUM = 'maximum'
class ImageVolume:
pass
@classmethod
def default(cls):
return cls.HOST_MODEL
@dataclass
class CPUTopology:
sockets: int
cores: int
threads: int
def validate(self, vcpus: int) -> None:
if self.sockets * self.cores * self.threads == vcpus:
return
raise ValueError("CPU topology must match the number of 'vcpus'")
@dataclass
class CPUInfo:
vendor: str
model: str
required_features: list[str]
disabled_features: list[str]
@dataclass
class VolumeInfo:
name: str
path: Path
capacity: int
@dataclass
class CloudInitConfig:
pass
user_data: str = ''
meta_data: str = ''
class Boot(Enum):
BIOS = 'bios'
UEFI = 'uefi'
@classmethod
def default(cls):
return cls.BIOS
@dataclass
class BootMenu:
enabled: bool = False
timeout: int = 3000
class BootOrder:
pass
class VirtualMachineInstaller:
def __init__(self, session: libvirt.virConnect):
self.session = session
self.info = {}
def __init__(self, session: 'LibvirtSession'):
self.connection = session.connection # libvirt.virConnect object
self.domcaps = fromstring(
self.connection.getDomainCapabilities())
self.arch = self.domcaps.xpath('/domainCapabilities/arch/text()')[0]
self.virttype = self.domcaps.xpath(
'/domainCapabilities/domain/text()')[0]
self.emulator = self.domcaps.xpath(
'/domainCapabilities/path/text()')[0]
self.machine = self.domcaps.xpath(
'/domainCapabilities/machine/text()')[0]
def install(
self,
name: str | None = None,
title: str | None = None,
description: str = '',
os: str | None = None,
image: ImageVolume | None = None,
volumes: list['VolumeInfo'] | None = None,
vcpus: int = 0,
vcpu_info: vCPUInfo | None = None,
vcpu_mode: vCPUMode | None = None,
vcpu_topology: vCPUTopology | None = None,
memory: int = 0,
boot: Boot = Boot.BIOS,
boot_menu: bool = False,
boot_order: BootOrder = ('cdrom', 'hd'),
cloud_init: CloudInitConfig | None = None):
self,
name: str | None = None,
title: str | None = None,
description: str = '',
os: str | None = None,
image: UUID | None = None,
volumes: list['VolumeInfo'] | None = None,
vcpus: int = 0,
vcpu_info: CPUInfo | None = None,
vcpu_mode: CPUMode = CPUMode.default(),
vcpu_topology: CPUTopology | None = None,
memory: int = 0,
boot: Boot = Boot.default(),
boot_menu: BootMenu = BootMenu(),
boot_order: tuple[str] = ('cdrom', 'hd'),
cloud_init: CloudInitConfig | None = None):
"""
Install virtual machine with passed parameters.
If no `vcpu_info` is None select best CPU wich can be provided by
hypervisor. Choosen CPU depends on `vcpu_mode`, default is 'custom'.
See CPUMode for more info. Default `vcpu_topology` is: 1 socket,
`vcpus` cores, 1 threads.
`memory` must be integer value in mebibytes e.g. 4094 MiB = 4 GiB.
Volumes must be passed as list of VolumeInfo objects. Minimum one
volume is required.
"""
domcaps = DomainCapabilities(self.session.session)
name = self._validate_name(name)
if vcpu_topology is None:
vcpu_topology = vCPUTopology(
{'sockets': 1, 'cores': vcpus, 'threads': 1})
self._validate_topology(vcpus, vcpu_topology)
vcpu_topology = CPUTopology(sockets=1, cores=vcpus, threads=1)
vcpu_topology.validate(vcpus)
if vcpu_info is None:
if not vcpu_mode:
vcpu_mode = vCPUMode.CUSTOM.value
xml_cpu = domcaps.best_cpu(vcpu_mode)
vcpu_mode = CPUMode.CUSTOM.value
xml_cpu = self._choose_best_cpu(vcpu_mode)
else:
raise NotImplementedError('Custom CPU not implemented')
xml_domain = Constructor().gen_domain_xml(
xml_domain = xml.Constructor().gen_domain_xml(
name=name,
title=title if title else name,
desc=description if description else '',
vcpus=vcpus,
# vcpu_topology=vcpu_topology,
# vcpu_info=vcpu_info,
memory=memory,
domain_type='hvm',
machine=domcaps.machine,
arch=domcaps.arch,
boot_order=('cdrom', 'hd'),
machine=self.machine,
arch=self.arch,
# boot_menu=boot_menu,
boot_order=boot_order,
cpu=xml_cpu,
mac=random_mac()
mac=mac.random_mac()
)
xml_volume = xml.Constructor().gen_volume_xml(
dev='vda', mode='rw', path='')
virconn = self.connection
virstor = virconn.storagePoolLookupByName('default')
# Мб использовать storageVolLookupByPath вместо поиска по имени
etalon_volume = virstor.storageVolLookupByName('debian_bookworm.qcow2')
return xml_domain
def _validate_name(self, name):
def _validate_name(self, name) -> str:
if name is None:
raise ValueError("'name' cannot be empty")
if isinstance(name, str):
@ -82,13 +164,27 @@ class VirtualMachineInstaller:
return name.lower()
raise TypeError(f"'name' must be 'str', not {type(name)}")
def _validate_topology(self, vcpus, topology):
sockets = topology['sockets']
cores = topology['cores']
threads = topology['threads']
if sockets * cores * threads == vcpus:
return
raise ValueError("CPU topology must match the number of 'vcpus'")
def _choose_best_cpu(self, mode: CPUMode) -> str:
if mode == 'host-passthrough':
xml = '<cpu mode="host-passthrough" migratable="on"/>'
elif mode == 'maximum':
xml = '<cpu mode="maximum" migratable="on"/>'
elif mode in ['host-model', 'custom']:
cpus = self.domcaps.xpath(
f'/domainCapabilities/cpu/mode[@name="{mode}"]')[0]
cpus.tag = 'cpu'
for attr in cpus.attrib.keys():
del cpus.attrib[attr]
arch = SubElement(cpus, 'arch')
arch.text = self.arch
xmlcpus = tostring(cpus, encoding='unicode', pretty_print=True)
xml = self.connection.baselineHypervisorCPU(
self.emulator, self.arch, self.machine, self.virttype, [xmlcpus])
else:
raise ValueError(
f'CPU mode must be in {[v.value for v in CPUMode]}, '
f"but passed '{mode}'")
return textwrap.indent(xml, ' ' * 2)
def _define(self, xml: str):
self.session.defineXML(xml)
def _define(self, xml: str) -> None:
self.connection.defineXML(xml)

View File

@ -2,6 +2,7 @@ import logging
import libvirt
from ..volume import VolumeInfo
from .base import VirtualMachineBase
from .exceptions import VMError
@ -65,7 +66,7 @@ class VirtualMachine(VirtualMachineBase):
logger.info('Starting VM: vm=%s', self.domain_name)
if self.is_running:
logger.warning('VM vm=%s is already started, nothing to do',
self.domain_name)
self.domain_name)
return
try:
self.domain.create()
@ -73,7 +74,7 @@ class VirtualMachine(VirtualMachineBase):
raise VMError(
f'Cannot start vm={self.domain_name}: {err}') from err
def shutdown(self, mode: str | None = None) -> None:
def shutdown(self, method: str | None = None) -> None:
"""
Send signal to guest OS to shutdown. Supports several modes:
* GUEST_AGENT - use guest agent
@ -82,26 +83,26 @@ class VirtualMachine(VirtualMachineBase):
* SIGKILL - send SIGKILL to QEMU process. May corrupt guest data!
If mode is not passed use 'NORMAL' mode.
"""
MODES = {
METHODS = {
'GUEST_AGENT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT,
'SIGTERM': libvirt.VIR_DOMAIN_DESTROY_GRACEFUL,
'SIGKILL': libvirt.VIR_DOMAIN_DESTROY_DEFAULT
}
if mode is None:
mode = 'NORMAL'
if not isinstance(mode, str):
raise ValueError(f"Mode must be a 'str', not {type(mode)}")
if mode.upper() not in MODES:
raise ValueError(f"Unsupported mode: '{mode}'")
if method is None:
method = 'NORMAL'
if not isinstance(method, str):
raise ValueError(f"Mode must be a 'str', not {type(method)}")
if method.upper() not in METHODS:
raise ValueError(f"Unsupported mode: '{method}'")
try:
if mode in ['GUEST_AGENT', 'NORMAL']:
self.domain.shutdownFlags(flags=MODES.get(mode))
elif mode in ['SIGTERM', 'SIGKILL']:
self.domain.destroyFlags(flags=MODES.get(mode))
if method in ['GUEST_AGENT', 'NORMAL']:
self.domain.shutdownFlags(flags=METHODS.get(method))
elif method in ['SIGTERM', 'SIGKILL']:
self.domain.destroyFlags(flags=METHODS.get(method))
except libvirt.libvirtError as err:
raise VMError(f'Cannot shutdown vm={self.domain_name} with '
f'mode={mode}: {err}') from err
f'method={method}: {err}') from err
def reset(self) -> None:
"""
@ -186,10 +187,26 @@ class VirtualMachine(VirtualMachineBase):
raise VMError(
f'Cannot set memory for vm={self.domain_name}: {err}') from err
def attach_device(self, device: str):
pass
def attach_device(self, dev_xml: str, hotplug: bool = False):
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE +
libvirt.VIR_DOMAIN_AFFECT_CONFIG)
else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
self.domain.attachDeviceFlags(dev_xml, flags=flags)
def detach_device(self, device: str):
def detach_device(self, dev_xml: str, hotplug: bool = False):
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE +
libvirt.VIR_DOMAIN_AFFECT_CONFIG)
else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
self.domain.detachDeviceFlags(dev_xml, flags=flags)
def resize_volume(self, vol_info: VolumeInfo, online: bool = False):
# Этот метод должен принимать описание волюма и в зависимости от
# флага online вызывать virStorageVolResize или virDomainBlockResize
# https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainBlockResize
pass
def list_ssh_keys(self, user: str):
@ -201,8 +218,8 @@ class VirtualMachine(VirtualMachineBase):
def remove_ssh_keys(self, user: str):
pass
def set_user_password(self, user: str):
pass
def set_user_password(self, user: str, password: str):
self.domain.setUserPassword(user, password)
def dump_xml(self) -> str:
return self.domain.XMLDesc()