some updates
This commit is contained in:
parent
43033b5a0d
commit
2870708365
55
README.md
55
README.md
@ -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 решение — ВМ будет автоматически запускаться на другой ноде из пула при отключении оригинальной ноды. Технически это можно реализовать как создание ВМ с аналогичными характеристиками на другой ноде с подключением к тому же самому сетевому диску. Сразу нужно отметить, что для реализации:
|
||||||
|
|
||||||
|
@ -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 *
|
@ -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__)
|
@ -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__)
|
@ -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."""
|
||||||
|
|
@ -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
73
computelib/utils/xml.py
Normal 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)
|
@ -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
|
@ -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
168
computelib/vm/installer.py
Normal 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)
|
@ -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
|
2
computelib/volume/__init__.py
Normal file
2
computelib/volume/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
from .storage_pool import StoragePool
|
||||||
|
from .volume import DiskInfo, Volume, VolumeInfo
|
@ -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()]
|
@ -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:
|
10
config.toml
10
config.toml
@ -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
|
||||||
|
@ -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)
|
|
@ -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)
|
|
@ -1,2 +0,0 @@
|
|||||||
from .storage_pool import StoragePool
|
|
||||||
from .volume import Volume, VolumeInfo
|
|
@ -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",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user