diff --git a/Makefile b/Makefile index 23fdf9e..35f8839 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -SRC = na/ +SRC = computelib/ all: build diff --git a/README.md b/README.md index a07cab5..65e307a 100644 --- a/README.md +++ b/README.md @@ -1,43 +1,28 @@ -# Compute Node Agent +# Compute Node Agent library -Агент для работы на ворк-нодах. В этом репозитории развивается базовая библиотека для взаимодействия с libvirt и выполнения основных операций. +В этом репозитории развивается базовая библиотека для взаимодействия с libvirt и выполнения операций с виртуальными машинами. Фокус на QEMU/KVM. -# Как это должно выглядеть - -`node-agent` должен стать обычным DEB-пакетом. Вместе с самим приложением пойдут вспомагательные утилиты: - -- `na-vmctl` virsh на минималках, который дёргает код из Node Agent. Выполняет базовые операции с VM, установку и миграцию и т.п. Реализована частично. -- `na-vmexec`. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна. Реализована целиком. -- `na-volctl`. Предполагается здесь оставить всю работу с дисками. Не реализована. - -Этими утилитами нет цели заменять virsh, бцдет реализован только специфичный для Node Agent функционал. - -Зависимости (версии из APT репозитория Debian 12): +# Зависимости (версии из APT репозитория Debian 12): - `python3-lxml` 4.9.2 - `python3-docopt` 0.6.2 - `python3-libvirt` 9.0.0 -`docopt` скорее всего будет выброшен в будущем, так как интерфейс CLI сильно усложнится. - Минимальная поддерживаемая версия Python — `3.11`, потому, что можем. +# Утилиты + +- `na-vmctl` virsh на минималках. Выполняет базовые операции с VM, установку и миграцию и т.п. +- `na-vmexec`. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна. +- `na-volctl`. Предполагается здесь оставить всю работу с дисками. Не реализована. + # API Кодовая база растёт, необходимо автоматически генерировать документацию, в README её больше не будет. В структуре проекта сейчас бардак, многое будет переосмыслено и переделано позже. Основная цель на текущем этапе — получить минимально работающий код, с помощью которого возможно выполнить установку виртуальной машины и как-то управлять ею. -Базовые сущности: - -- `LivbirtSession` - обёртка над объектом `libvirt.virConnect`. -- `VirtualMachine` - класс для работы с доменами, через него выполняется большинство действий. -- `VirtualMachineInstaller` - класс для установки ВМ, выполняет кучу проверок, генерирует XML конфиг и т.п. [В ПРОЦЕССЕ] -- `StoragePool` - обёртка над `libvirt.virStoragePool`. -- `Volume` - класс для управления дисками. -- `VolumeInfo` - датакласс хранящий информацию о диске, с помощью метода `to_xml()` получаем XML описание. -- `GuestAgent` - понятно что это. -- `ConfigLoader` - загрузчик TOML-конфига. +Есть набор классов, предоставляющих собой интерфейсы для взаимодействия с виртуальными машинами, стораджами, дисками и т.п. Датаклассы описывают сущности и имеют метод `to_xml()` для получения XML конфига для `libvirt`. Смысл датакласса в том, чтобы иметь один объект, содержащий в себе нормальные легкочитаемые аттрибуты и XML описание сущности одновременно. # TODO @@ -49,12 +34,20 @@ - [x] Работа со StoragePool - [x] Создание блочных устройств - [x] Подключение/отключение устройств - - [ ] Метод install() + - [x] Метод install() + - [ ] Выбор между SeaBIOS/UEFI + - [ ] Выбор модели процессора - [ ] Установка ВМ (нормальный вариант) -- [x] Управление дисками (всратый вариант) +- [x] Управление дисками + - [x] Локальные qcow2 + - [ ] ZVOL + - [ ] Сетевые диски + - [ ] Живой ресайз файловой системы (?) - [x] Удаление ВМ - [x] Изменение CPU + - [ ] Полноценный hotplug - [x] Изменение RAM + - [ ] Полноценный hotplug - [ ] Миграция ВМ между нодами - [x] Работа с qemu-ga - [x] Управление питанием @@ -68,15 +61,15 @@ # Заметки -## Что там с LXC? +### Что там с LXC? Можно добавить поддержку LXC, но только после реализации основного функционала для QEMU/KVM. -## Будущее этой библиотеки +### Будущее этой библиотеки -Нужно ей придумать название. +Нужно задействовать билиотеку [libosinfo](https://libosinfo.org/) для получения информации об операционных системах. См. [How to populate Libosinfo DataBase](https://wiki.libvirt.org/HowToPopulateLibosinfoDB.html). -## Failover +### Failover В перспективе для ВМ с сетевыми дисками возможно организовать Failover решение — ВМ будет автоматически запускаться на другой ноде из пула при отключении оригинальной ноды. Технически это можно реализовать как создание ВМ с аналогичными характеристиками на другой ноде с подключением к тому же самому сетевому диску. Сразу нужно отметить, что для реализации: diff --git a/node_agent/__init__.py b/computelib/__init__.py similarity index 100% rename from node_agent/__init__.py rename to computelib/__init__.py index 78d357c..d0c0f5f 100644 --- a/node_agent/__init__.py +++ b/computelib/__init__.py @@ -1,5 +1,5 @@ from .config import ConfigLoader -from .session import LibvirtSession from .exceptions import * -from .volume import * +from .session import LibvirtSession from .vm import * +from .volume import * diff --git a/node_agent/cli/vmctl.py b/computelib/cli/vmctl.py similarity index 100% rename from node_agent/cli/vmctl.py rename to computelib/cli/vmctl.py index 21c447c..17f6ca4 100644 --- a/node_agent/cli/vmctl.py +++ b/computelib/cli/vmctl.py @@ -22,9 +22,9 @@ import sys import libvirt from docopt import docopt +from ..exceptions import VMError, VMNotFound from ..session import LibvirtSession from ..vm import VirtualMachine -from ..exceptions import VMError, VMNotFound logger = logging.getLogger(__name__) diff --git a/node_agent/cli/vmexec.py b/computelib/cli/vmexec.py similarity index 100% rename from node_agent/cli/vmexec.py rename to computelib/cli/vmexec.py index f23c13a..3bc5f11 100644 --- a/node_agent/cli/vmexec.py +++ b/computelib/cli/vmexec.py @@ -18,9 +18,9 @@ import sys import libvirt from docopt import docopt +from ..exceptions import GuestAgentError, VMNotFound from ..session import LibvirtSession from ..vm import GuestAgent -from ..exceptions import GuestAgentError, VMNotFound logger = logging.getLogger(__name__) diff --git a/node_agent/cli/volctl.py b/computelib/cli/volctl.py similarity index 100% rename from node_agent/cli/volctl.py rename to computelib/cli/volctl.py diff --git a/node_agent/config.py b/computelib/config.py similarity index 100% rename from node_agent/config.py rename to computelib/config.py diff --git a/node_agent/exceptions.py b/computelib/exceptions.py similarity index 99% rename from node_agent/exceptions.py rename to computelib/exceptions.py index 6d38011..12ca8e8 100644 --- a/node_agent/exceptions.py +++ b/computelib/exceptions.py @@ -1,6 +1,7 @@ class ConfigLoaderError(Exception): """Bad config file syntax, unreachable file or bad config schema.""" + class LibvirtSessionError(Exception): """Something went wrong while connecting to libvirtd.""" diff --git a/node_agent/session.py b/computelib/session.py similarity index 51% rename from node_agent/session.py rename to computelib/session.py index d01a428..fc5660e 100644 --- a/node_agent/session.py +++ b/computelib/session.py @@ -2,15 +2,18 @@ from contextlib import AbstractContextManager import libvirt +from .exceptions import LibvirtSessionError, VMNotFound from .vm import GuestAgent, VirtualMachine from .volume import StoragePool -from .exceptions import LibvirtSessionError, VMNotFound class LibvirtSession(AbstractContextManager): 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): return self @@ -18,47 +21,31 @@ class LibvirtSession(AbstractContextManager): def __exit__(self, exception_type, exception_value, exception_traceback): self.close() - def _connect(self, connection_uri: str) -> libvirt.virConnect: + def get_machine(self, name: str) -> VirtualMachine: try: - return libvirt.open(connection_uri) - 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) + return VirtualMachine(self.connection.lookupByName(name)) 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 _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]: - 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, - flags: int | None = None) -> GuestAgent: - return GuestAgent(self._get_domain(name), timeout, flags) + def get_guest_agent(self, name: str, + timeout: int | None = None) -> GuestAgent: + 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: - 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()] def close(self) -> None: diff --git a/node_agent/utils/__init__.py b/computelib/utils/__init__.py similarity index 100% rename from node_agent/utils/__init__.py rename to computelib/utils/__init__.py diff --git a/node_agent/utils/mac.py b/computelib/utils/mac.py similarity index 100% rename from node_agent/utils/mac.py rename to computelib/utils/mac.py diff --git a/computelib/utils/xml.py b/computelib/utils/xml.py new file mode 100644 index 0000000..c2060ae --- /dev/null +++ b/computelib/utils/xml.py @@ -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) diff --git a/node_agent/vm/__init__.py b/computelib/vm/__init__.py similarity index 53% rename from node_agent/vm/__init__.py rename to computelib/vm/__init__.py index 9c2cb30..9589876 100644 --- a/node_agent/vm/__init__.py +++ b/computelib/vm/__init__.py @@ -1,3 +1,3 @@ from .guest_agent import GuestAgent -from .installer import CPUMode, CPUTopology, VirtualMachineInstaller +from .installer import VirtualMachineInstaller from .virtual_machine import VirtualMachine diff --git a/node_agent/vm/base.py b/computelib/vm/base.py similarity index 100% rename from node_agent/vm/base.py rename to computelib/vm/base.py diff --git a/node_agent/vm/guest_agent.py b/computelib/vm/guest_agent.py similarity index 98% rename from node_agent/vm/guest_agent.py rename to computelib/vm/guest_agent.py index 39a5f29..4faf037 100644 --- a/node_agent/vm/guest_agent.py +++ b/computelib/vm/guest_agent.py @@ -26,6 +26,9 @@ class GuestAgent(VirtualMachineBase): shellexec() High-level method for executing shell commands on guest. Command 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, diff --git a/computelib/vm/installer.py b/computelib/vm/installer.py new file mode 100644 index 0000000..afdd740 --- /dev/null +++ b/computelib/vm/installer.py @@ -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 = '' + elif mode == 'maximum': + xml = '' + 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) diff --git a/node_agent/vm/virtual_machine.py b/computelib/vm/virtual_machine.py similarity index 89% rename from node_agent/vm/virtual_machine.py rename to computelib/vm/virtual_machine.py index c097ac3..5d9794a 100644 --- a/node_agent/vm/virtual_machine.py +++ b/computelib/vm/virtual_machine.py @@ -156,7 +156,7 @@ class VirtualMachine(VirtualMachineBase): if nvcpus == 0: raise VMError(f'Cannot set zero vCPUs vm={self.domain_name}') 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) else: flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG @@ -177,31 +177,33 @@ class VirtualMachine(VirtualMachineBase): if memory == 0: raise VMError(f'Cannot set zero memory vm={self.domain_name}') 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) else: flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG 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: 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: - flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE + + 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) + 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: - flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE + + 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) + self.domain.detachDeviceFlags(device_info.to_xml(), flags=flags) def resize_volume(self, vol_info: VolumeInfo, online: bool = False): # Этот метод должен принимать описание волюма и в зависимости от @@ -218,13 +220,14 @@ class VirtualMachine(VirtualMachineBase): def remove_ssh_keys(self, user: str): 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) def dump_xml(self) -> str: return self.domain.XMLDesc() - def delete(self, delete_volumes: bool = False): + def delete(self, delete_volumes: bool = False) -> None: """Undefine VM.""" self.shutdown(method='SIGTERM') self.domain.undefine() + # todo: delete local volumes diff --git a/computelib/volume/__init__.py b/computelib/volume/__init__.py new file mode 100644 index 0000000..c70cd93 --- /dev/null +++ b/computelib/volume/__init__.py @@ -0,0 +1,2 @@ +from .storage_pool import StoragePool +from .volume import DiskInfo, Volume, VolumeInfo diff --git a/node_agent/volume/storage_pool.py b/computelib/volume/storage_pool.py similarity index 51% rename from node_agent/volume/storage_pool.py rename to computelib/volume/storage_pool.py index b0246c2..4bac4a4 100644 --- a/node_agent/volume/storage_pool.py +++ b/computelib/volume/storage_pool.py @@ -1,6 +1,8 @@ import logging +from collections import namedtuple import libvirt +from lxml import etree from ..exceptions import StoragePoolError from .volume import Volume, VolumeInfo @@ -17,15 +19,24 @@ class StoragePool: def name(self) -> str: 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: return self.pool.XMLDesc() - def create(self): - pass - - def delete(self): - pass - def refresh(self) -> None: self.pool.refresh() @@ -33,30 +44,27 @@ class StoragePool: """ Create storage volume and return Volume instance. """ - logger.info(f'Create storage volume vol={vol_info.name} ' - f'in pool={self.pool}') + logger.info('Create storage volume vol=%s in pool=%s', + vol_info.name, self.pool) vol = self.pool.createXML( vol_info.to_xml(), flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA) return Volume(self.pool, vol) def get_volume(self, name: str) -> Volume | None: - """ - Lookup and return Volume instance or None. - """ - logger.info(f'Lookup for storage volume vol={name} ' - f'in pool={self.pool.name}') + """Lookup and return Volume instance or None.""" + logger.info('Lookup for storage volume vol=%s in pool=%s', + name, self.pool.name) try: vol = self.pool.storageVolLookupByName(name) return Volume(self.pool, vol) except libvirt.libvirtError as err: - if (err.get_error_domain() == libvirt.VIR_FROM_STORAGE - err.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL): + if (err.get_error_domain() == libvirt.VIR_FROM_STORAGE or + err.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL): logger.error(err.get_error_message()) return None - else: - logger.error(f'libvirt error: {err}') - raise StoragePoolError(f'libvirt error: {err}') from err + logger.error('libvirt error: %s' err) + raise StoragePoolError(f'libvirt error: {err}') from err def list_volumes(self) -> list[Volume]: return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()] diff --git a/node_agent/volume/volume.py b/computelib/volume/volume.py similarity index 73% rename from node_agent/volume/volume.py rename to computelib/volume/volume.py index 56a88e3..f10772b 100644 --- a/node_agent/volume/volume.py +++ b/computelib/volume/volume.py @@ -2,8 +2,8 @@ from dataclasses import dataclass from time import time import libvirt +from lxml import etree from lxml.builder import E -from lxml.etree import tostring @dataclass @@ -30,7 +30,23 @@ class VolumeInfo: E.compat('1.1'), 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: diff --git a/config.toml b/config.toml index d684204..4815e28 100644 --- a/config.toml +++ b/config.toml @@ -1,26 +1,26 @@ [libvirt] -uri = 'qemu:///session' +uri = 'qemu:///system' [logging] level = 'INFO' driver = 'file' -file = '/var/log/node-agent.log' +file = '/var/log/compute/compute.log' [[storages.pools]] name = 'ssd-nvme' enabled = true default = true -path = '/srv/vm-volumes/ssd-nvme' +path = '/vm-volumes/ssd-nvme' [[storages.pools]] name = 'hdd' enabled = true -path = '/srv/vm-volumes/hdd' +path = '/vm-volumes/hdd' [[storages.pools]] name = 'images' enabled = true -path = '/srv/vm-images/vendor' +path = '/vm-images/vendor' [virtual_machine.defaults] autostart = true diff --git a/node_agent/utils/xml.py b/node_agent/utils/xml.py deleted file mode 100644 index e47ef33..0000000 --- a/node_agent/utils/xml.py +++ /dev/null @@ -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) diff --git a/node_agent/vm/installer.py b/node_agent/vm/installer.py deleted file mode 100644 index 6318062..0000000 --- a/node_agent/vm/installer.py +++ /dev/null @@ -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 = '' - elif mode == 'maximum': - xml = '' - 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) diff --git a/node_agent/volume/__init__.py b/node_agent/volume/__init__.py deleted file mode 100644 index 924dc4d..0000000 --- a/node_agent/volume/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .storage_pool import StoragePool -from .volume import Volume, VolumeInfo diff --git a/pyproject.toml b/pyproject.toml index 813e2be..311e817 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,26 +1,24 @@ [tool.poetry] -name = "node-agent" +name = "computelib" version = "0.1.0" -description = "Node Agent" +description = "Compute Node Agent library" authors = ["ge "] readme = "README.md" [tool.poetry.dependencies] python = "^3.11" -libvirt-python = "9.0.0" # 9.0.0 on Debian 12 -lxml = "^4.9.2" # 4.9.2 on Debian 12 -docopt = "^0.6.2" # 0.6.2 on Debian 12 +libvirt-python = "9.0.0" +lxml = "^4.9.2" +docopt = "^0.6.2" [tool.poetry.scripts] -na-vmctl = "node_agent.cli.vmctl:cli" -na-vmexec = "node_agent.cli.vmexec:cli" +na-vmctl = "computelib.cli.vmctl:cli" +na-vmexec = "computelib.cli.vmexec:cli" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" -[tool.yapf] - [tool.pylint."MESSAGES CONTROL"] disable = [ "invalid-name", @@ -30,4 +28,3 @@ disable = [ "import-error", "too-many-arguments", ] -