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",
]
-