some updates

This commit is contained in:
ge 2023-09-23 21:24:56 +03:00
parent 43033b5a0d
commit 2870708365
25 changed files with 367 additions and 453 deletions

View File

@ -1,4 +1,4 @@
SRC = na/ SRC = computelib/
all: build all: build

View File

@ -1,43 +1,28 @@
# Compute Node Agent # Compute Node Agent library
Агент для работы на ворк-нодах. В этом репозитории развивается базовая библиотека для взаимодействия с libvirt и выполнения основных операций. В этом репозитории развивается базовая библиотека для взаимодействия с libvirt и выполнения операций с виртуальными машинами. Фокус на QEMU/KVM.
# Как это должно выглядеть # Зависимости (версии из APT репозитория Debian 12):
`node-agent` должен стать обычным DEB-пакетом. Вместе с самим приложением пойдут вспомагательные утилиты:
- `na-vmctl` virsh на минималках, который дёргает код из Node Agent. Выполняет базовые операции с VM, установку и миграцию и т.п. Реализована частично.
- `na-vmexec`. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна. Реализована целиком.
- `na-volctl`. Предполагается здесь оставить всю работу с дисками. Не реализована.
Этими утилитами нет цели заменять virsh, бцдет реализован только специфичный для Node Agent функционал.
Зависимости (версии из APT репозитория Debian 12):
- `python3-lxml` 4.9.2 - `python3-lxml` 4.9.2
- `python3-docopt` 0.6.2 - `python3-docopt` 0.6.2
- `python3-libvirt` 9.0.0 - `python3-libvirt` 9.0.0
`docopt` скорее всего будет выброшен в будущем, так как интерфейс CLI сильно усложнится.
Минимальная поддерживаемая версия Python — `3.11`, потому, что можем. Минимальная поддерживаемая версия Python — `3.11`, потому, что можем.
# Утилиты
- `na-vmctl` virsh на минималках. Выполняет базовые операции с VM, установку и миграцию и т.п.
- `na-vmexec`. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна.
- `na-volctl`. Предполагается здесь оставить всю работу с дисками. Не реализована.
# API # API
Кодовая база растёт, необходимо автоматически генерировать документацию, в README её больше не будет. Кодовая база растёт, необходимо автоматически генерировать документацию, в README её больше не будет.
В структуре проекта сейчас бардак, многое будет переосмыслено и переделано позже. Основная цель на текущем этапе — получить минимально работающий код, с помощью которого возможно выполнить установку виртуальной машины и как-то управлять ею. В структуре проекта сейчас бардак, многое будет переосмыслено и переделано позже. Основная цель на текущем этапе — получить минимально работающий код, с помощью которого возможно выполнить установку виртуальной машины и как-то управлять ею.
Базовые сущности: Есть набор классов, предоставляющих собой интерфейсы для взаимодействия с виртуальными машинами, стораджами, дисками и т.п. Датаклассы описывают сущности и имеют метод `to_xml()` для получения XML конфига для `libvirt`. Смысл датакласса в том, чтобы иметь один объект, содержащий в себе нормальные легкочитаемые аттрибуты и XML описание сущности одновременно.
- `LivbirtSession` - обёртка над объектом `libvirt.virConnect`.
- `VirtualMachine` - класс для работы с доменами, через него выполняется большинство действий.
- `VirtualMachineInstaller` - класс для установки ВМ, выполняет кучу проверок, генерирует XML конфиг и т.п. [В ПРОЦЕССЕ]
- `StoragePool` - обёртка над `libvirt.virStoragePool`.
- `Volume` - класс для управления дисками.
- `VolumeInfo` - датакласс хранящий информацию о диске, с помощью метода `to_xml()` получаем XML описание.
- `GuestAgent` - понятно что это.
- `ConfigLoader` - загрузчик TOML-конфига.
# TODO # TODO
@ -49,12 +34,20 @@
- [x] Работа со StoragePool - [x] Работа со StoragePool
- [x] Создание блочных устройств - [x] Создание блочных устройств
- [x] Подключение/отключение устройств - [x] Подключение/отключение устройств
- [ ] Метод install() - [x] Метод install()
- [ ] Выбор между SeaBIOS/UEFI
- [ ] Выбор модели процессора
- [ ] Установка ВМ (нормальный вариант) - [ ] Установка ВМ (нормальный вариант)
- [x] Управление дисками (всратый вариант) - [x] Управление дисками
- [x] Локальные qcow2
- [ ] ZVOL
- [ ] Сетевые диски
- [ ] Живой ресайз файловой системы (?)
- [x] Удаление ВМ - [x] Удаление ВМ
- [x] Изменение CPU - [x] Изменение CPU
- [ ] Полноценный hotplug
- [x] Изменение RAM - [x] Изменение RAM
- [ ] Полноценный hotplug
- [ ] Миграция ВМ между нодами - [ ] Миграция ВМ между нодами
- [x] Работа с qemu-ga - [x] Работа с qemu-ga
- [x] Управление питанием - [x] Управление питанием
@ -68,15 +61,15 @@
# Заметки # Заметки
## Что там с LXC? ### Что там с LXC?
Можно добавить поддержку LXC, но только после реализации основного функционала для QEMU/KVM. Можно добавить поддержку LXC, но только после реализации основного функционала для QEMU/KVM.
## Будущее этой библиотеки ### Будущее этой библиотеки
Нужно ей придумать название. Нужно задействовать билиотеку [libosinfo](https://libosinfo.org/) для получения информации об операционных системах. См. [How to populate Libosinfo DataBase](https://wiki.libvirt.org/HowToPopulateLibosinfoDB.html).
## Failover ### Failover
В перспективе для ВМ с сетевыми дисками возможно организовать Failover решение — ВМ будет автоматически запускаться на другой ноде из пула при отключении оригинальной ноды. Технически это можно реализовать как создание ВМ с аналогичными характеристиками на другой ноде с подключением к тому же самому сетевому диску. Сразу нужно отметить, что для реализации: В перспективе для ВМ с сетевыми дисками возможно организовать Failover решение — ВМ будет автоматически запускаться на другой ноде из пула при отключении оригинальной ноды. Технически это можно реализовать как создание ВМ с аналогичными характеристиками на другой ноде с подключением к тому же самому сетевому диску. Сразу нужно отметить, что для реализации:

View File

@ -1,5 +1,5 @@
from .config import ConfigLoader from .config import ConfigLoader
from .session import LibvirtSession
from .exceptions import * from .exceptions import *
from .volume import * from .session import LibvirtSession
from .vm import * from .vm import *
from .volume import *

View File

@ -22,9 +22,9 @@ import sys
import libvirt import libvirt
from docopt import docopt from docopt import docopt
from ..exceptions import VMError, VMNotFound
from ..session import LibvirtSession from ..session import LibvirtSession
from ..vm import VirtualMachine from ..vm import VirtualMachine
from ..exceptions import VMError, VMNotFound
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -18,9 +18,9 @@ import sys
import libvirt import libvirt
from docopt import docopt from docopt import docopt
from ..exceptions import GuestAgentError, VMNotFound
from ..session import LibvirtSession from ..session import LibvirtSession
from ..vm import GuestAgent from ..vm import GuestAgent
from ..exceptions import GuestAgentError, VMNotFound
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)

View File

@ -1,6 +1,7 @@
class ConfigLoaderError(Exception): class ConfigLoaderError(Exception):
"""Bad config file syntax, unreachable file or bad config schema.""" """Bad config file syntax, unreachable file or bad config schema."""
class LibvirtSessionError(Exception): class LibvirtSessionError(Exception):
"""Something went wrong while connecting to libvirtd.""" """Something went wrong while connecting to libvirtd."""

View File

@ -2,15 +2,18 @@ from contextlib import AbstractContextManager
import libvirt import libvirt
from .exceptions import LibvirtSessionError, VMNotFound
from .vm import GuestAgent, VirtualMachine from .vm import GuestAgent, VirtualMachine
from .volume import StoragePool from .volume import StoragePool
from .exceptions import LibvirtSessionError, VMNotFound
class LibvirtSession(AbstractContextManager): class LibvirtSession(AbstractContextManager):
def __init__(self, uri: str = 'qemu:///system'): def __init__(self, uri: str = 'qemu:///system'):
self.connection = self._connect(uri) try:
self.connection = libvirt.open(uri)
except libvirt.libvirtError as err:
raise LibvirtSessionError(err) from err
def __enter__(self): def __enter__(self):
return self return self
@ -18,47 +21,31 @@ class LibvirtSession(AbstractContextManager):
def __exit__(self, exception_type, exception_value, exception_traceback): def __exit__(self, exception_type, exception_value, exception_traceback):
self.close() self.close()
def _connect(self, connection_uri: str) -> libvirt.virConnect: def get_machine(self, name: str) -> VirtualMachine:
try: try:
return libvirt.open(connection_uri) return VirtualMachine(self.connection.lookupByName(name))
except libvirt.libvirtError as err:
raise LibvirtSessionError(
f'Failed to open connection to the hypervisor: {err}') from err
def _get_domain(self, name: str) -> libvirt.virDomain:
try:
return self.connection.lookupByName(name)
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN: if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
raise VMNotFound(name) from err raise VMNotFound(name) from err
raise LibvirtSessionError(err) from err raise LibvirtSessionError(err) from err
def _list_all_domains(self) -> list[libvirt.virDomain]:
try:
return self.connection.listAllDomains()
except libvirt.libvirtError as err:
raise LibvirtSessionError(err) from err
def _get_storage_pool(self, name: str) -> libvirt.virStoragePool:
try:
return self.connection.storagePoolLookupByName(name)
except libvirt.libvirtError as err:
raise LibvirtSessionError(err) from err
def get_machine(self, name: str) -> VirtualMachine:
return VirtualMachine(self._get_domain(name))
def list_machines(self) -> list[VirtualMachine]: def list_machines(self) -> list[VirtualMachine]:
return [VirtualMachine(dom) for dom in self._list_all_domains()] return [VirtualMachine(dom) for dom in
self.connection.listAllDomains()]
def get_guest_agent(self, name: str, timeout: int | None = None, def get_guest_agent(self, name: str,
flags: int | None = None) -> GuestAgent: timeout: int | None = None) -> GuestAgent:
return GuestAgent(self._get_domain(name), timeout, flags) try:
return GuestAgent(self.connection.lookupByName(name), timeout)
except libvirt.libvirtError as err:
if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
raise VMNotFound(name) from err
raise LibvirtSessionError(err) from err
def get_storage_pool(self, name: str) -> StoragePool: def get_storage_pool(self, name: str) -> StoragePool:
return StoragePool(self._get_storage_pool(name)) return StoragePool(self.connection.storagePoolLookupByName(name))
def list_storage_pools(self): def list_storage_pools(self) -> list[StoragePool]:
return [StoragePool(p) for p in self.connection.listStoragePools()] return [StoragePool(p) for p in self.connection.listStoragePools()]
def close(self) -> None: def close(self) -> None:

73
computelib/utils/xml.py Normal file
View File

@ -0,0 +1,73 @@
from lxml.etree import Element, QName, SubElement
class Constructor:
"""
The XML constructor. This class builds XML configs for libvirt.
"""
def construct_xml(self,
tag: dict,
namespace: str | None = None,
nsprefix: str | None = None,
root: Element = None) -> Element:
"""
Shortly this recursive function transforms dictonary to XML.
Return etree.Element built from dict with following structure::
{
'name': 'device', # tag name
'text': '', # optional key
'values': { # optional key, must be a dict of key-value pairs
'type': 'disk'
},
children: [] # optional key, must be a list of dicts
}
Child elements must have the same structure. Infinite `children` nesting
is allowed.
"""
use_ns = False
if isinstance(namespace, str) and isinstance(nsprefix, str):
use_ns = True
# Create element
if root is None:
if use_ns:
element = Element(QName(namespace, tag['name']),
nsmap={nsprefix: namespace})
else:
element = Element(tag['name'])
else:
if use_ns:
element = SubElement(root, QName(namespace, tag['name']))
else:
element = SubElement(root, tag['name'])
# Fill up element with content
if 'text' in tag.keys():
element.text = tag['text']
if 'values' in tag.keys():
for key in tag['values'].keys():
element.set(str(key), str(tag['values'][key]))
if 'children' in tag.keys():
for child in tag['children']:
element.append(
self.construct_xml(child,
namespace=namespace,
nsprefix=nsprefix,
root=element))
return element
def add_meta(self, xml: Element, data: dict,
namespace: str, nsprefix: str) -> None:
"""
Add metadata to domain. See:
https://libvirt.org/formatdomain.html#general-metadata
"""
metadata = metadata_old = xml.xpath('/domain/metadata')[0]
metadata.append(
self.construct_xml(
data,
namespace=namespace,
nsprefix=nsprefix,
))
xml.replace(metadata_old, metadata)

View File

@ -1,3 +1,3 @@
from .guest_agent import GuestAgent from .guest_agent import GuestAgent
from .installer import CPUMode, CPUTopology, VirtualMachineInstaller from .installer import VirtualMachineInstaller
from .virtual_machine import VirtualMachine from .virtual_machine import VirtualMachine

View File

@ -26,6 +26,9 @@ class GuestAgent(VirtualMachineBase):
shellexec() shellexec()
High-level method for executing shell commands on guest. Command High-level method for executing shell commands on guest. Command
must be passed as string. Wraps execute() method. must be passed as string. Wraps execute() method.
TODO:
check() method. Ping guest agent and check supported commands.
""" """
def __init__(self, domain: libvirt.virDomain, timeout: int | None = None, def __init__(self, domain: libvirt.virDomain, timeout: int | None = None,

168
computelib/vm/installer.py Normal file
View File

@ -0,0 +1,168 @@
import textwrap
from dataclasses import dataclass
from enum import Enum
from lxml import etree
from lxml.builder import E
from ..utils import mac
from ..volume import DiskInfo, VolumeInfo
@dataclass
class VirtualMachineInfo:
name: str
title: str
memory: int
vcpus: int
machine: str
emulator: str
arch: str
cpu: str # CPU full XML description
mac: str
description: str = ''
boot_order: tuple = ('cdrom', 'hd')
def to_xml(self) -> str:
xml = E.domain(
E.name(self.name),
E.title(self.title),
E.description(self.description),
E.metadata(),
E.memory(str(self.memory), unit='MB'),
E.currentMemory(str(self.memory), unit='MB'),
E.vcpu(str(self.vcpus), placement='static'),
type='kvm')
os = E.os(E.type('hvm', machine=self.machine, arch=self.arch))
for dev in self.boot_order:
os.append(E.boot(dev=dev))
xml.append(os)
xml.append(E.features(E.acpi(), E.apic()))
xml.append(etree.fromstring(self.cpu))
xml.append(E.on_poweroff('destroy'))
xml.append(E.on_reboot('restart'))
xml.append(E.on_crash('restart'))
xml.append(E.pm(
E('suspend-to-mem', enabled='no'),
E('suspend-to-disk', enabled='no'))
)
devices = E.devices()
devices.append(E.emulator(self.emulator))
devices.append(E.interface(
E.source(network='default'),
E.mac(address=self.mac),
type='network'))
devices.append(E.graphics(type='vnc', port='-1', autoport='yes'))
devices.append(E.input(type='tablet', bus='usb'))
devices.append(E.channel(
E.source(mode='bind'),
E.target(type='virtio', name='org.qemu.guest_agent.0'),
E.address(type='virtio-serial', controller='0', bus='0', port='1'),
type='unix')
)
devices.append(E.console(
E.target(type='serial', port='0'),
type='pty')
)
devices.append(E.video(
E.model(type='vga', vram='16384', heads='1', primary='yes'))
)
xml.append(devices)
return etree.tostring(xml, encoding='unicode', pretty_print=True)
class CPUMode(Enum):
HOST_MODEL = 'host-model'
HOST_PASSTHROUGH = 'host-passthrough'
CUSTOM = 'custom'
MAXIMUM = 'maximum'
@classmethod
def default(cls):
return cls.HOST_PASSTHROUGH
@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'")
class VirtualMachineInstaller:
def __init__(self, session: 'LibvirtSession'):
self.session = session
self.connection = session.connection # libvirt.virConnect object
self.domcaps = etree.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, data: 'VirtualMachineSchema'):
xml_cpu = self._choose_best_cpu(CPUMode.default())
xml_vm = VirtualMachineInfo(
name=data['name'],
title=data['title'],
vcpus=data['vcpus'],
memory=data['memory'],
machine=self.machine,
emulator=self.emulator,
arch=self.arch,
cpu=xml_cpu,
mac=mac.random_mac()
).to_xml()
self._define(xml_vm)
storage_pool = self.session.get_storage_pool('default')
etalon_vol = storage_pool.get_volume('bookworm.qcow2')
new_vol = VolumeInfo(
name=data['name'] +
'_disk_some_pattern.qcow2',
path=storage_pool.path +
'/' +
data['name'] +
'_disk_some_pattern.qcow2',
capacity=data['volume']['capacity'])
etalon_vol.clone(new_vol)
vm = self.session.get_machine(data['name'])
vm.attach_device(DiskInfo(path=new_vol.path, target='vda'))
vm.set_vcpus(data['vcpus'])
vm.set_memory(data['memory'])
vm.start()
vm.set_autostart(enabled=True)
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 = etree.SubElement(cpus, 'arch')
arch.text = self.arch
xmlcpus = etree.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) -> None:
self.connection.defineXML(xml)

View File

@ -156,7 +156,7 @@ class VirtualMachine(VirtualMachineBase):
if nvcpus == 0: if nvcpus == 0:
raise VMError(f'Cannot set zero vCPUs vm={self.domain_name}') raise VMError(f'Cannot set zero vCPUs vm={self.domain_name}')
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING: if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE + flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
libvirt.VIR_DOMAIN_AFFECT_CONFIG) libvirt.VIR_DOMAIN_AFFECT_CONFIG)
else: else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
@ -177,31 +177,33 @@ class VirtualMachine(VirtualMachineBase):
if memory == 0: if memory == 0:
raise VMError(f'Cannot set zero memory vm={self.domain_name}') raise VMError(f'Cannot set zero memory vm={self.domain_name}')
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING: if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE + flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
libvirt.VIR_DOMAIN_AFFECT_CONFIG) libvirt.VIR_DOMAIN_AFFECT_CONFIG)
else: else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
try: try:
self.domain.setVcpusFlags(memory * 1024, flags=flags) self.domain.setMemoryFlags(memory * 1024,
libvirt.VIR_DOMAIN_MEM_MAXIMUM)
self.domain.setMemoryFlags(memory * 1024, flags=flags)
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
raise VMError( raise VMError(
f'Cannot set memory for vm={self.domain_name}: {err}') from err f'Cannot set memory for vm={self.domain_name} {memory=}: {err}') from err
def attach_device(self, dev_xml: str, hotplug: bool = False): def attach_device(self, device_info: 'DeviceInfo', hotplug: bool = False):
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING: if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE + flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
libvirt.VIR_DOMAIN_AFFECT_CONFIG) libvirt.VIR_DOMAIN_AFFECT_CONFIG)
else: else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
self.domain.attachDeviceFlags(dev_xml, flags=flags) self.domain.attachDeviceFlags(device_info.to_xml(), flags=flags)
def detach_device(self, dev_xml: str, hotplug: bool = False): def detach_device(self, device_info: 'DeviceInfo', hotplug: bool = False):
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING: if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE + flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
libvirt.VIR_DOMAIN_AFFECT_CONFIG) libvirt.VIR_DOMAIN_AFFECT_CONFIG)
else: else:
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
self.domain.detachDeviceFlags(dev_xml, flags=flags) self.domain.detachDeviceFlags(device_info.to_xml(), flags=flags)
def resize_volume(self, vol_info: VolumeInfo, online: bool = False): def resize_volume(self, vol_info: VolumeInfo, online: bool = False):
# Этот метод должен принимать описание волюма и в зависимости от # Этот метод должен принимать описание волюма и в зависимости от
@ -218,13 +220,14 @@ class VirtualMachine(VirtualMachineBase):
def remove_ssh_keys(self, user: str): def remove_ssh_keys(self, user: str):
pass pass
def set_user_password(self, user: str, password: str): def set_user_password(self, user: str, password: str) -> None:
self.domain.setUserPassword(user, password) self.domain.setUserPassword(user, password)
def dump_xml(self) -> str: def dump_xml(self) -> str:
return self.domain.XMLDesc() return self.domain.XMLDesc()
def delete(self, delete_volumes: bool = False): def delete(self, delete_volumes: bool = False) -> None:
"""Undefine VM.""" """Undefine VM."""
self.shutdown(method='SIGTERM') self.shutdown(method='SIGTERM')
self.domain.undefine() self.domain.undefine()
# todo: delete local volumes

View File

@ -0,0 +1,2 @@
from .storage_pool import StoragePool
from .volume import DiskInfo, Volume, VolumeInfo

View File

@ -1,6 +1,8 @@
import logging import logging
from collections import namedtuple
import libvirt import libvirt
from lxml import etree
from ..exceptions import StoragePoolError from ..exceptions import StoragePoolError
from .volume import Volume, VolumeInfo from .volume import Volume, VolumeInfo
@ -17,15 +19,24 @@ class StoragePool:
def name(self) -> str: def name(self) -> str:
return self.pool.name() return self.pool.name()
@property
def path(self) -> str:
xml = etree.fromstring(self.pool.XMLDesc())
return xml.xpath('/pool/target/path/text()')[0]
@property
def usage(self) -> 'StoragePoolUsage':
xml = etree.fromstring(self.pool.XMLDesc())
StoragePoolUsage = namedtuple('StoagePoolUsage',
['capacity', 'allocation', 'available'])
return StoragePoolUsage(
capacity=int(xml.xpath('/pool/capacity/text()')[0])
allocation=int(xml.xpath('/pool/allocation/text()')[0])
available=int(xml.xpath('/pool/available/text()')[0]))
def dump_xml(self) -> str: def dump_xml(self) -> str:
return self.pool.XMLDesc() return self.pool.XMLDesc()
def create(self):
pass
def delete(self):
pass
def refresh(self) -> None: def refresh(self) -> None:
self.pool.refresh() self.pool.refresh()
@ -33,30 +44,27 @@ class StoragePool:
""" """
Create storage volume and return Volume instance. Create storage volume and return Volume instance.
""" """
logger.info(f'Create storage volume vol={vol_info.name} ' logger.info('Create storage volume vol=%s in pool=%s',
f'in pool={self.pool}') vol_info.name, self.pool)
vol = self.pool.createXML( vol = self.pool.createXML(
vol_info.to_xml(), vol_info.to_xml(),
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA) flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA)
return Volume(self.pool, vol) return Volume(self.pool, vol)
def get_volume(self, name: str) -> Volume | None: def get_volume(self, name: str) -> Volume | None:
""" """Lookup and return Volume instance or None."""
Lookup and return Volume instance or None. logger.info('Lookup for storage volume vol=%s in pool=%s',
""" name, self.pool.name)
logger.info(f'Lookup for storage volume vol={name} '
f'in pool={self.pool.name}')
try: try:
vol = self.pool.storageVolLookupByName(name) vol = self.pool.storageVolLookupByName(name)
return Volume(self.pool, vol) return Volume(self.pool, vol)
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
if (err.get_error_domain() == libvirt.VIR_FROM_STORAGE if (err.get_error_domain() == libvirt.VIR_FROM_STORAGE or
err.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL): err.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL):
logger.error(err.get_error_message()) logger.error(err.get_error_message())
return None return None
else: logger.error('libvirt error: %s' err)
logger.error(f'libvirt error: {err}') raise StoragePoolError(f'libvirt error: {err}') from err
raise StoragePoolError(f'libvirt error: {err}') from err
def list_volumes(self) -> list[Volume]: def list_volumes(self) -> list[Volume]:
return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()] return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()]

View File

@ -2,8 +2,8 @@ from dataclasses import dataclass
from time import time from time import time
import libvirt import libvirt
from lxml import etree
from lxml.builder import E from lxml.builder import E
from lxml.etree import tostring
@dataclass @dataclass
@ -30,7 +30,23 @@ class VolumeInfo:
E.compat('1.1'), E.compat('1.1'),
E.features(E.lazy_refcounts()) E.features(E.lazy_refcounts())
)) ))
return tostring(xml, encoding='unicode', pretty_print=True) return etree.tostring(xml, encoding='unicode', pretty_print=True)
@dataclass
class DiskInfo:
target: str
path: str
readonly: bool = False
def to_xml(self) -> str:
xml = E.disk(type='file', device='disk')
xml.append(E.driver(name='qemu', type='qcow2', cache='writethrough'))
xml.append(E.source(file=self.path))
xml.append(E.target(dev=self.target, bus='virtio'))
if self.readonly:
xml.append(E.readonly())
return etree.tostring(xml, encoding='unicode', pretty_print=True)
class Volume: class Volume:

View File

@ -1,26 +1,26 @@
[libvirt] [libvirt]
uri = 'qemu:///session' uri = 'qemu:///system'
[logging] [logging]
level = 'INFO' level = 'INFO'
driver = 'file' driver = 'file'
file = '/var/log/node-agent.log' file = '/var/log/compute/compute.log'
[[storages.pools]] [[storages.pools]]
name = 'ssd-nvme' name = 'ssd-nvme'
enabled = true enabled = true
default = true default = true
path = '/srv/vm-volumes/ssd-nvme' path = '/vm-volumes/ssd-nvme'
[[storages.pools]] [[storages.pools]]
name = 'hdd' name = 'hdd'
enabled = true enabled = true
path = '/srv/vm-volumes/hdd' path = '/vm-volumes/hdd'
[[storages.pools]] [[storages.pools]]
name = 'images' name = 'images'
enabled = true enabled = true
path = '/srv/vm-images/vendor' path = '/vm-images/vendor'
[virtual_machine.defaults] [virtual_machine.defaults]
autostart = true autostart = true

View File

@ -1,145 +0,0 @@
from lxml.builder import E
from lxml.etree import Element, QName, SubElement, fromstring, tostring
class Constructor:
"""
The XML constructor. This class builds XML configs for libvirt.
"""
def gen_domain_xml(self, name: str, title: str, desc: str, memory: int,
vcpus: int, domain_type: str, machine: str, arch: str,
boot_order: tuple, cpu: str, mac: str) -> str:
"""
Return basic libvirt domain configuration.
"""
domain = E.domain(
E.name(name),
E.title(title),
E.description(desc),
E.metadata(),
E.memory(str(memory), unit='MB'),
E.currentMemory(str(memory), unit='MB'),
E.vcpu(str(vcpus), placement='static'),
type='kvm'
)
os = E.os(E.type(domain_type, machine=machine, arch=arch))
for dev in boot_order:
os.append(E.boot(dev=dev))
domain.append(os)
domain.append(E.features(E.acpi(), E.apic()))
domain.append(fromstring(cpu))
domain.append(E.on_poweroff('destroy'))
domain.append(E.on_reboot('restart'))
domain.append(E.on_crash('restart'))
domain.append(E.pm(
E('suspend-to-mem', enabled='no'),
E('suspend-to-disk', enabled='no'))
)
devices = E.devices()
devices.append(E.emulator('/usr/bin/qemu-system-x86_64'))
devices.append(E.interface(
E.source(network='default'),
E.mac(address=mac),
type='network')
)
devices.append(E.graphics(type='vnc', port='-1', autoport='yes'))
devices.append(E.input(type='tablet', bus='usb'))
devices.append(E.channel(
E.source(mode='bind'),
E.target(type='virtio', name='org.qemu.guest_agent.0'),
E.address(type='virtio-serial', controller='0', bus='0', port='1'),
type='unix')
)
devices.append(E.console(
E.target(type='serial', port='0'),
type='pty')
)
devices.append(E.video(
E.model(type='vga', vram='16384', heads='1', primary='yes'))
)
domain.append(devices)
return tostring(domain, encoding='unicode', pretty_print=True).strip()
def gen_volume_xml(self, dev: str, mode: str, path: str) -> str:
"""
Todo: No hardcode
https://libvirt.org/formatdomain.html#hard-drives-floppy-disks-cdroms
"""
volume = E.disk(type='file', device='disk')
volume.append(
E.driver(
name='qemu',
type='qcow2',
cache='writethrough'))
volume.append(E.source(file=path))
volume.append(E.target(dev=dev, bus='virtio'))
if mode.lower() == 'ro':
volume.append(E.readonly())
return tostring(volume, encoding='unicode', pretty_print=True).strip()
def construct_xml(self,
tag: dict,
namespace: str | None = None,
nsprefix: str | None = None,
root: Element = None) -> Element:
"""
Shortly this recursive function transforms dictonary to XML.
Return etree.Element built from dict with following structure::
{
'name': 'device', # tag name
'text': '', # optional key
'values': { # optional key, must be a dict of key-value pairs
'type': 'disk'
},
children: [] # optional key, must be a list of dicts
}
Child elements must have the same structure. Infinite `children` nesting
is allowed.
"""
use_ns = False
if isinstance(namespace, str) and isinstance(nsprefix, str):
use_ns = True
# Create element
if root is None:
if use_ns:
element = Element(QName(namespace, tag['name']),
nsmap={nsprefix: namespace})
else:
element = Element(tag['name'])
else:
if use_ns:
element = SubElement(root, QName(namespace, tag['name']))
else:
element = SubElement(root, tag['name'])
# Fill up element with content
if 'text' in tag.keys():
element.text = tag['text']
if 'values' in tag.keys():
for key in tag['values'].keys():
element.set(str(key), str(tag['values'][key]))
if 'children' in tag.keys():
for child in tag['children']:
element.append(
self.construct_xml(child,
namespace=namespace,
nsprefix=nsprefix,
root=element))
return element
def add_meta(self, xml: Element, data: dict,
namespace: str, nsprefix: str) -> None:
"""
Add metadata to domain. See:
https://libvirt.org/formatdomain.html#general-metadata
"""
metadata = metadata_old = xml.xpath('/domain/metadata')[0]
metadata.append(
self.construct_xml(
data,
namespace=namespace,
nsprefix=nsprefix,
))
xml.replace(metadata_old, metadata)

View File

@ -1,190 +0,0 @@
import re
import textwrap
from dataclasses import dataclass
from enum import Enum
from pathlib import Path
from uuid import UUID
from lxml.etree import SubElement, fromstring, tostring
from ..utils import mac, xml
class CPUMode(Enum):
HOST_MODEL = 'host-model'
HOST_PASSTHROUGH = 'host-passthrough'
CUSTOM = 'custom'
MAXIMUM = 'maximum'
@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:
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 VirtualMachineInstaller:
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: 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.
"""
name = self._validate_name(name)
if vcpu_topology is None:
vcpu_topology = CPUTopology(sockets=1, cores=vcpus, threads=1)
vcpu_topology.validate(vcpus)
if vcpu_info is None:
if not vcpu_mode:
vcpu_mode = CPUMode.CUSTOM.value
xml_cpu = self._choose_best_cpu(vcpu_mode)
else:
raise NotImplementedError('Custom CPU not implemented')
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=self.machine,
arch=self.arch,
# boot_menu=boot_menu,
boot_order=boot_order,
cpu=xml_cpu,
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) -> str:
if name is None:
raise ValueError("'name' cannot be empty")
if isinstance(name, str):
if not re.match(r"^[a-z0-9_]+$", name, re.I):
raise ValueError(
"'name' can contain only letters, numbers "
"and underscore.")
return name.lower()
raise TypeError(f"'name' must be 'str', not {type(name)}")
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) -> None:
self.connection.defineXML(xml)

View File

@ -1,2 +0,0 @@
from .storage_pool import StoragePool
from .volume import Volume, VolumeInfo

View File

@ -1,26 +1,24 @@
[tool.poetry] [tool.poetry]
name = "node-agent" name = "computelib"
version = "0.1.0" version = "0.1.0"
description = "Node Agent" description = "Compute Node Agent library"
authors = ["ge <ge@nixhacks.net>"] authors = ["ge <ge@nixhacks.net>"]
readme = "README.md" readme = "README.md"
[tool.poetry.dependencies] [tool.poetry.dependencies]
python = "^3.11" python = "^3.11"
libvirt-python = "9.0.0" # 9.0.0 on Debian 12 libvirt-python = "9.0.0"
lxml = "^4.9.2" # 4.9.2 on Debian 12 lxml = "^4.9.2"
docopt = "^0.6.2" # 0.6.2 on Debian 12 docopt = "^0.6.2"
[tool.poetry.scripts] [tool.poetry.scripts]
na-vmctl = "node_agent.cli.vmctl:cli" na-vmctl = "computelib.cli.vmctl:cli"
na-vmexec = "node_agent.cli.vmexec:cli" na-vmexec = "computelib.cli.vmexec:cli"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" build-backend = "poetry.core.masonry.api"
[tool.yapf]
[tool.pylint."MESSAGES CONTROL"] [tool.pylint."MESSAGES CONTROL"]
disable = [ disable = [
"invalid-name", "invalid-name",
@ -30,4 +28,3 @@ disable = [
"import-error", "import-error",
"too-many-arguments", "too-many-arguments",
] ]