upd
This commit is contained in:
parent
91478b8122
commit
e8133af392
3
.gitignore
vendored
3
.gitignore
vendored
@ -1,8 +1,7 @@
|
|||||||
__pycache__/
|
__pycache__/
|
||||||
*.pyc
|
*.pyc
|
||||||
*~
|
*~
|
||||||
domain.xml
|
dom*
|
||||||
domgen.py
|
|
||||||
na
|
na
|
||||||
dist/
|
dist/
|
||||||
P@ssw0rd
|
P@ssw0rd
|
||||||
|
140
README.md
140
README.md
@ -1,6 +1,6 @@
|
|||||||
# Node Agent
|
# Compute Node Agent
|
||||||
|
|
||||||
Агент для работы на ворк-нодах.
|
Агент для работы на ворк-нодах. В этом репозитории развивается базовая библиотека для взаимодействия с libvirt и выполнения основных операций.
|
||||||
|
|
||||||
# Как это должно выглядеть
|
# Как это должно выглядеть
|
||||||
|
|
||||||
@ -18,117 +18,54 @@
|
|||||||
- `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`, потому, что можем.
|
||||||
|
|
||||||
# Классы
|
# API
|
||||||
|
|
||||||
Весь пакет разбит на модули, а основной функционал на классы.
|
Кодовая база растёт, необходимо автоматически генерировать документацию в README её больше небудет.
|
||||||
|
|
||||||
## `ConfigLoader`
|
В структуре проекта сейчас бардак, многое будет переосмыслено и переделано позже. Основная цель на текущем этапе — получить минимально работающий код, с помощью которого возможно выполнить установку виртуальной машины и как-то управлять ею.
|
||||||
|
|
||||||
Наследуется от `UserDict`. Принимает в конструктор путь до файла, после чего экземпляром `ConfigLoader` можно пользоваться как обычным словарём. Вызывается внутри `LibvirtSession` при инициализации.
|
Базовые сущности:
|
||||||
|
|
||||||
## `LibvirtSession`
|
- `LivbirtSession` - обёртка над объектом `libvirt.virConnect`.
|
||||||
|
- `VirtualMachine` - класс для работы с доменами, через него выполняется большинство действий.
|
||||||
Устанавливает сессию с libvirtd и создаёт объект virConnect. Класс умеет принимать в конструктор один аргумент — путь до файла конфигурации, но его можно опустить.
|
- `VirtualMachineInstaller` - класс для установки ВМ, выполняет кучу проверок, генерирует XML конфиг и т.п.
|
||||||
|
- `QemuAgent` - понятно что это.
|
||||||
|
- `ConfigLoader` - загрузчик TOML-конфига, возможно будет выброшен на мороз.
|
||||||
|
|
||||||
```python
|
```python
|
||||||
from node_agent import LibvirtSession
|
from na import LibvirtSession
|
||||||
|
from na.vm import VirtualMachineInstaller
|
||||||
|
|
||||||
session = LibvirtSession()
|
|
||||||
|
session = LibvirtSession('config.toml')
|
||||||
|
compute = VirtualMachineInstaller(session).install(
|
||||||
|
name='devuan',
|
||||||
|
vcpus=4,
|
||||||
|
vcpu_mode='host-model',
|
||||||
|
memory=2048,
|
||||||
|
)
|
||||||
|
print(compute)
|
||||||
session.close()
|
session.close()
|
||||||
```
|
```
|
||||||
|
|
||||||
Также этот класс является контекстным менеджером и его можно использвоать так:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from node_agent import LibvirtSession, VirtualMachine
|
|
||||||
|
|
||||||
with LibvirtSession() as session:
|
|
||||||
vm = VirtualMachine(session, 'имя_вм')
|
|
||||||
vm.status
|
|
||||||
```
|
|
||||||
|
|
||||||
## `VirtualMachine`
|
|
||||||
|
|
||||||
Класс для базового управления виртуалкой. В конструктор принимает объект LibvirtSession и создаёт объект `virDomain`.
|
|
||||||
|
|
||||||
## `QemuAgent`
|
|
||||||
|
|
||||||
Класс для работы с агентом на гостях. Инициализируется аналогично `VirtualMachine`. Его можно считать законченным. Он умеет:
|
|
||||||
|
|
||||||
- Выполнять шелл команды через метод `shellexec()`.
|
|
||||||
- Выполнять любые команды QEMU через `execute()`.
|
|
||||||
|
|
||||||
Также способен:
|
|
||||||
|
|
||||||
- Поллить выполнение команды. То есть можно дождаться вывода долгой команды.
|
|
||||||
- Декодировать base64 вывод STDERR и STDOUT если надо.
|
|
||||||
- Отправлять данные на STDIN.
|
|
||||||
|
|
||||||
## `XMLConstructor`
|
|
||||||
|
|
||||||
Класс для генерации XML конфигов для либвирта и редактирования XML. Пока умеет очень мало и требует перепиливания. Возможно стоит разбить его на несколько классов. Пример работы с ним:
|
|
||||||
|
|
||||||
```python
|
|
||||||
from node_agent.xml import XMLConstructor
|
|
||||||
|
|
||||||
domain_xml = XMLConstructor()
|
|
||||||
domain_xml.gen_domain_xml(
|
|
||||||
name='13',
|
|
||||||
title='',
|
|
||||||
vcpus=2,
|
|
||||||
cpu_vendor='Intel',
|
|
||||||
cpu_model='Broadwell',
|
|
||||||
memory=2048,
|
|
||||||
volume='/srv/vm-volumes/ef0bcd68-02c2-4f31-ae96-14d2bda5a97b.qcow2',
|
|
||||||
)
|
|
||||||
tags_meta = {
|
|
||||||
'name': 'tags',
|
|
||||||
'children': [
|
|
||||||
{'name': 'god_mode'},
|
|
||||||
{'name': 'service'}
|
|
||||||
]
|
|
||||||
}
|
|
||||||
domain_xml.add_meta(tags_meta, namespace='http://half-it-stack.org/xmlns/tags-meta', nsprefix='tags')
|
|
||||||
print(domain_xml.to_string())
|
|
||||||
```
|
|
||||||
|
|
||||||
В итоге должен получиться какой-то конфиг для ВМ.
|
|
||||||
|
|
||||||
Имеет метод `construct_xml()`, который позволяет привести словарь Python в XML элемент (обхект `lxml.etree.Element`). Пример:
|
|
||||||
|
|
||||||
```python
|
|
||||||
>>> from lxml.etree import tostring
|
|
||||||
>>> from na.xml import XMLConstructor
|
|
||||||
>>> xml = XMLConstructor()
|
|
||||||
>>> tag = {
|
|
||||||
... 'name': 'mytag',
|
|
||||||
... 'values': {
|
|
||||||
... 'firstname': 'John',
|
|
||||||
... 'lastname': 'Doe'
|
|
||||||
... },
|
|
||||||
... 'text': 'Hello!',
|
|
||||||
... 'children': [{'name': 'okay'}]
|
|
||||||
... }
|
|
||||||
>>> element = xml.construct_xml(tag)
|
|
||||||
>>> print(tostring(element).decode())
|
|
||||||
'<mytag firstname="John" lastname="Doe">Hello!<okay/></mytag>'
|
|
||||||
>>>
|
|
||||||
```
|
|
||||||
|
|
||||||
Функция рекурсивная, так что теоретически можно положить бесконечное число вложенных элементов в `children`. С аргументами `namespace` и `nsprefix` будет сгенерирован XML с неймспейсом, Ваш кэп.
|
|
||||||
|
|
||||||
# TODO
|
# TODO
|
||||||
|
|
||||||
- [ ] Установка ВМ
|
- [ ] Установка ВМ
|
||||||
- [x] Конструктор XML (базовый)
|
- [x] Конструктор XML (базовый)
|
||||||
|
- [x] Автоматический выбор модели процессора
|
||||||
- [ ] Метод создания дисков
|
- [ ] Метод создания дисков
|
||||||
- [ ] Дефайн, запуск и автостарт ВМ
|
- [x] Дефайн, запуск и автостарт ВМ
|
||||||
|
- [ ] Работа со StoragePool
|
||||||
|
- [ ] Создание блочных устройств
|
||||||
|
- [ ] Подключение/отключение устройств
|
||||||
- [ ] Управление дисками
|
- [ ] Управление дисками
|
||||||
- [ ] Удаление ВМ
|
- [ ] Удаление ВМ
|
||||||
- [ ] Изменение CPU
|
- [x] Изменение CPU
|
||||||
- [ ] Изменение RAM
|
- [x] Изменение RAM
|
||||||
- [ ] Миграция ВМ между нодами
|
- [ ] Миграция ВМ между нодами
|
||||||
- [x] Работа с qemu-ga
|
- [x] Работа с qemu-ga
|
||||||
- [x] Управление питанием
|
- [x] Управление питанием
|
||||||
@ -138,11 +75,20 @@ print(domain_xml.to_string())
|
|||||||
- [ ] SSH-ключи
|
- [ ] SSH-ключи
|
||||||
- [ ] Сеть
|
- [ ] Сеть
|
||||||
- [ ] Создание снапшотов
|
- [ ] Создание снапшотов
|
||||||
|
- [ ] Поддержка выделения гарантированной доли CPU
|
||||||
|
|
||||||
# Заметки
|
# Заметки
|
||||||
|
|
||||||
xml.py наверное лучше реализовать через lxml.objectify: https://stackoverflow.com/questions/47304314/adding-child-element-to-xml-in-python
|
## Будущее этой библиотеки
|
||||||
|
|
||||||
???: https://www.geeksforgeeks.org/reading-and-writing-xml-files-in-python/
|
Либа
|
||||||
|
|
||||||
Минимальный рабочий XML: https://access.redhat.com/documentation/ru-ru/red_hat_enterprise_linux/6/html/virtualization_administration_guide/section-libvirt-dom-xml-example
|
## Failover
|
||||||
|
|
||||||
|
В перспективе для ВМ с сетевыми дисками возможно организовать Failover решение — ВМ будет автоматически запускаться на другой ноде из пула при отключении оригинальной ноды. Технически это можно реализовать как создание ВМ с аналогичными характеристиками на другой ноде с подключением к тому же самому сетевому диску. Сразу нужно отметить, что для реализации:
|
||||||
|
|
||||||
|
- Нужно где-то хранить и регулярно обновлять информацию о конфигурации ВМ для воссоздания ВМ
|
||||||
|
- Нужно иметь "плавающие адреса", чтобы переключить трафик на новую ноду
|
||||||
|
- Необходимо выполнять failover по чётким критериям: нода полностью недоступна более X времени, маунт сетевого диска отвалился и т.п.
|
||||||
|
- Как быть с целостностью данных на сетевом диске? При аварии на ноде, данные могли быть повреждены, тогда failover на тот же диск ничего не даст.
|
||||||
|
- Сетевой диск должен быть зарезервирован средствами распределённой ФС
|
||||||
|
30
config.toml
30
config.toml
@ -1,35 +1,29 @@
|
|||||||
[general]
|
|
||||||
# Наверное стоит создавать локи в виде файлов во время операций с ВМ
|
|
||||||
# /var/node-agent/locks/vms/{vm}
|
|
||||||
locks_dir = '/var/node-agent/locks'
|
|
||||||
|
|
||||||
[libvirt]
|
[libvirt]
|
||||||
uri = 'qemu:///system'
|
uri = 'qemu:///session'
|
||||||
|
|
||||||
[logging]
|
[logging]
|
||||||
level = 'INFO'
|
level = 'INFO'
|
||||||
|
driver = 'file'
|
||||||
file = '/var/log/node-agent.log'
|
file = '/var/log/node-agent.log'
|
||||||
|
|
||||||
[volumes]
|
[[storages.pools]]
|
||||||
|
|
||||||
[[volumes.pools]]
|
|
||||||
name = 'ssd-nvme'
|
name = 'ssd-nvme'
|
||||||
enabled = true
|
enabled = true
|
||||||
default = true
|
default = true
|
||||||
path = '/srv/vm-volumes/ssd-nvme'
|
path = '/srv/vm-volumes/ssd-nvme'
|
||||||
|
|
||||||
[[volumes.pools]]
|
[[storages.pools]]
|
||||||
name = 'hdd'
|
name = 'hdd'
|
||||||
enabled = true
|
enabled = true
|
||||||
path = '/srv/vm-volumes/hdd'
|
path = '/srv/vm-volumes/hdd'
|
||||||
|
|
||||||
[vms.defaults]
|
[[storages.pools]]
|
||||||
# Какие-то значения по-умолчанию, используемые при создании/работе с ВМ
|
name = 'images'
|
||||||
# Эти параметры также будут аффектить на CLI утилиты
|
enabled = true
|
||||||
autostart = true # ставить виртуалки в автостарт после установки
|
path = '/srv/vm-images/vendor'
|
||||||
start = true # запускать ВМ после установки
|
|
||||||
|
[virtual_machine.defaults]
|
||||||
|
autostart = true
|
||||||
|
start = true
|
||||||
cpu_vendor = 'Intel'
|
cpu_vendor = 'Intel'
|
||||||
cpu_model = 'Broadwell'
|
cpu_model = 'Broadwell'
|
||||||
|
|
||||||
[vms.images]
|
|
||||||
path = '/srv/vm-images'
|
|
||||||
|
@ -5,10 +5,14 @@ Usage: na-vmctl [options] status <machine>
|
|||||||
na-vmctl [options] is-running <machine>
|
na-vmctl [options] is-running <machine>
|
||||||
na-vmctl [options] start <machine>
|
na-vmctl [options] start <machine>
|
||||||
na-vmctl [options] shutdown <machine> [-f|--force] [-9|--sigkill]
|
na-vmctl [options] shutdown <machine> [-f|--force] [-9|--sigkill]
|
||||||
|
na-vmctl [options] set-vcpus <machine> <nvcpus>
|
||||||
|
na-vmctl [options] set-memory <machine> <memory>
|
||||||
|
na-vmctl [options] list [-a|--all]
|
||||||
|
|
||||||
Options:
|
Options:
|
||||||
-c, --config <file> Config file [default: /etc/node-agent/config.yaml]
|
-c, --config <file> Config file [default: /etc/node-agent/config.yaml]
|
||||||
-l, --loglvl <lvl> Logging level
|
-l, --loglvl <lvl> Logging level
|
||||||
|
-a, --all List all machines including inactive
|
||||||
-f, --force Force action. On shutdown calls graceful destroy()
|
-f, --force Force action. On shutdown calls graceful destroy()
|
||||||
-9, --sigkill Send SIGKILL to QEMU process. Not affects without --force
|
-9, --sigkill Send SIGKILL to QEMU process. Not affects without --force
|
||||||
"""
|
"""
|
||||||
@ -35,6 +39,44 @@ class Color:
|
|||||||
NONE = '\033[0m'
|
NONE = '\033[0m'
|
||||||
|
|
||||||
|
|
||||||
|
class Table:
|
||||||
|
"""Print table. Example::
|
||||||
|
|
||||||
|
t = Table()
|
||||||
|
t.header(['KEY', 'VALUE']) # header is optional
|
||||||
|
t.row(['key 1', 'value 1'])
|
||||||
|
t.row(['key 2', 'value 2'])
|
||||||
|
t.rows(
|
||||||
|
[
|
||||||
|
['key 3', 'value 3'],
|
||||||
|
['key 4', 'value 4']
|
||||||
|
]
|
||||||
|
)
|
||||||
|
t.print()
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, whitespace: str = '\t'):
|
||||||
|
self.__rows = []
|
||||||
|
self.__whitespace = whitespace
|
||||||
|
|
||||||
|
def header(self, columns: list):
|
||||||
|
self.__rows.insert(0, [str(col) for col in columns])
|
||||||
|
|
||||||
|
def row(self, row: list):
|
||||||
|
self.__rows.append([str(col) for col in row])
|
||||||
|
|
||||||
|
def rows(self, rows: list):
|
||||||
|
for row in rows:
|
||||||
|
self.row(row)
|
||||||
|
|
||||||
|
def print(self):
|
||||||
|
widths = [max(map(len, col)) for col in zip(*self.__rows)]
|
||||||
|
for row in self.__rows:
|
||||||
|
print(self.__whitespace.join(
|
||||||
|
(val.ljust(width) for val, width in zip(row, widths))))
|
||||||
|
|
||||||
|
|
||||||
def cli():
|
def cli():
|
||||||
args = docopt(__doc__)
|
args = docopt(__doc__)
|
||||||
config = pathlib.Path(args['--config']) or None
|
config = pathlib.Path(args['--config']) or None
|
||||||
@ -49,6 +91,16 @@ def cli():
|
|||||||
|
|
||||||
with LibvirtSession(config) as session:
|
with LibvirtSession(config) as session:
|
||||||
try:
|
try:
|
||||||
|
if args['list']:
|
||||||
|
vms = session.list_domains()
|
||||||
|
table = Table()
|
||||||
|
table.header(['NAME', 'STATE', 'AUTOSTART'])
|
||||||
|
for vm_ in vms:
|
||||||
|
vm_ = VirtualMachine(vm_)
|
||||||
|
table.row([vm_.name, vm_.status, vm_.is_autostart])
|
||||||
|
table.print()
|
||||||
|
sys.exit()
|
||||||
|
|
||||||
vm = VirtualMachine(session, machine)
|
vm = VirtualMachine(session, machine)
|
||||||
if args['status']:
|
if args['status']:
|
||||||
print(vm.status)
|
print(vm.status)
|
||||||
|
@ -8,7 +8,7 @@ NODEAGENT_CONFIG_FILE = os.getenv('NODEAGENT_CONFIG_FILE')
|
|||||||
NODEAGENT_DEFAULT_CONFIG_FILE = '/etc/node-agent/config.toml'
|
NODEAGENT_DEFAULT_CONFIG_FILE = '/etc/node-agent/config.toml'
|
||||||
|
|
||||||
|
|
||||||
class ConfigLoadError(Exception):
|
class ConfigLoaderError(Exception):
|
||||||
"""Bad config file syntax, unreachable file or bad config schema."""
|
"""Bad config file syntax, unreachable file or bad config schema."""
|
||||||
|
|
||||||
|
|
||||||
@ -27,11 +27,11 @@ class ConfigLoader(UserDict):
|
|||||||
return tomllib.load(config)
|
return tomllib.load(config)
|
||||||
# todo: config schema validation
|
# todo: config schema validation
|
||||||
except tomllib.TOMLDecodeError as tomlerr:
|
except tomllib.TOMLDecodeError as tomlerr:
|
||||||
raise ConfigLoadError(
|
raise ConfigLoaderError(
|
||||||
f'Bad TOML syntax in config file: {self.file}: {tomlerr}'
|
f'Bad TOML syntax in config file: {self.file}: {tomlerr}'
|
||||||
) from tomlerr
|
) from tomlerr
|
||||||
except (OSError, ValueError) as readerr:
|
except (OSError, ValueError) as readerr:
|
||||||
raise ConfigLoadError(
|
raise ConfigLoaderError(
|
||||||
f'Cannot read config file: {self.file}: {readerr}') from readerr
|
f'Cannot read config file: {self.file}: {readerr}') from readerr
|
||||||
|
|
||||||
def reload(self):
|
def reload(self):
|
||||||
|
@ -31,3 +31,21 @@ class LibvirtSession(AbstractContextManager):
|
|||||||
|
|
||||||
def close(self) -> None:
|
def close(self) -> None:
|
||||||
self.session.close()
|
self.session.close()
|
||||||
|
|
||||||
|
def list_domains(self):
|
||||||
|
return self.session.listAllDomains()
|
||||||
|
|
||||||
|
def get_domain(self, name: str) -> libvirt.virDomain:
|
||||||
|
try:
|
||||||
|
return self.session.lookupByName(name)
|
||||||
|
except libvirt.libvirtError as err:
|
||||||
|
if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
|
||||||
|
raise VMNotFound(name)
|
||||||
|
else:
|
||||||
|
raise LibvirtSessionError(err)
|
||||||
|
|
||||||
|
def get_storage_pool(self, name: str) -> libvirt.virStoragePool:
|
||||||
|
try:
|
||||||
|
return self.session.storagePoolLookupByName(name)
|
||||||
|
except libvirt.libvirtError as err:
|
||||||
|
raise LibvirtSessionError(err)
|
||||||
|
212
node_agent/utils/__old_xml.py
Normal file
212
node_agent/utils/__old_xml.py
Normal file
@ -0,0 +1,212 @@
|
|||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from lxml.builder import E
|
||||||
|
from lxml.etree import Element, QName, SubElement, tostring, fromstring
|
||||||
|
|
||||||
|
|
||||||
|
XPATH_DOM_NAME = '/domain/name'
|
||||||
|
XPATH_DOM_TITLE = '/domain/title'
|
||||||
|
XPATH_DOM_DESCRIPTION = '/domain/description'
|
||||||
|
XPATH_DOM_METADATA = '/domain/metadata'
|
||||||
|
XPATH_DOM_MEMORY = '/domain/memory'
|
||||||
|
XPATH_DOM_CURRENT_MEMORY = '/domain/currentMemory'
|
||||||
|
XPATH_DOM_VCPU = '/domain/vcpu'
|
||||||
|
XPATH_DOM_OS = '/domian/os'
|
||||||
|
XPATH_DOM_CPU = '/domain/cpu'
|
||||||
|
|
||||||
|
|
||||||
|
class Reader:
|
||||||
|
|
||||||
|
def __init__(xml: str):
|
||||||
|
self.xml = xml
|
||||||
|
self.el = fromstring(self.xml)
|
||||||
|
|
||||||
|
def get_domcaps_machine(self):
|
||||||
|
return self.el.xpath('/domainCapabilities/machine')[0].text
|
||||||
|
|
||||||
|
def get_domcaps_cpus(self):
|
||||||
|
# mode can be: custom, host-model, host-passthrough
|
||||||
|
return self.el.xpath('/domainCapabilities/cpu/mode[@name="custom"]')[0]
|
||||||
|
|
||||||
|
|
||||||
|
class Constructor:
|
||||||
|
"""
|
||||||
|
The XML constructor. This class builds XML configs for libvirt.
|
||||||
|
Features:
|
||||||
|
- Generate basic virtual machine XML. See gen_domain_xml()
|
||||||
|
- Generate virtual disk XML. See gen_volume_xml()
|
||||||
|
- Add arbitrary metadata to XML from special structured dict
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, xml: str | None = None):
|
||||||
|
self.xml_string = xml
|
||||||
|
self.xml = None
|
||||||
|
|
||||||
|
@property
|
||||||
|
def domain_xml(self):
|
||||||
|
return self.xml
|
||||||
|
|
||||||
|
def gen_domain_xml(self,
|
||||||
|
name: str,
|
||||||
|
title: str,
|
||||||
|
vcpus: int,
|
||||||
|
vcpu_vendor: str,
|
||||||
|
vcpu_model: str,
|
||||||
|
mac_addr: str,
|
||||||
|
memory: int,
|
||||||
|
volume: Path,
|
||||||
|
vcpu_features: dict | None = None,
|
||||||
|
desc: str = "") -> None:
|
||||||
|
"""
|
||||||
|
Generate default domain XML configuration for virtual machines.
|
||||||
|
See https://lxml.de/tutorial.html#the-e-factory for details.
|
||||||
|
"""
|
||||||
|
domain_xml = 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'),
|
||||||
|
E.os(
|
||||||
|
E.type('hvm', arch='x86_64'),
|
||||||
|
E.boot(dev='cdrom'),
|
||||||
|
E.boot(dev='hd'),
|
||||||
|
),
|
||||||
|
E.features(
|
||||||
|
E.acpi(),
|
||||||
|
E.apic(),
|
||||||
|
),
|
||||||
|
E.cpu(
|
||||||
|
E.vendor(vcpu_vendor),
|
||||||
|
E.model(vcpu_model, fallback='forbid'),
|
||||||
|
E.topology(sockets='1', dies='1', cores=str(vcpus),
|
||||||
|
threads='1'),
|
||||||
|
mode='custom',
|
||||||
|
match='exact',
|
||||||
|
check='partial',
|
||||||
|
),
|
||||||
|
E.on_poweroff('destroy'),
|
||||||
|
E.on_reboot('restart'),
|
||||||
|
E.on_crash('restart'),
|
||||||
|
E.pm(
|
||||||
|
E('suspend-to-mem', enabled='no'),
|
||||||
|
E('suspend-to-disk', enabled='no'),
|
||||||
|
),
|
||||||
|
E.devices(
|
||||||
|
E.emulator('/usr/bin/qemu-system-x86_64'),
|
||||||
|
E.disk(
|
||||||
|
E.driver(name='qemu', type='qcow2', cache='writethrough'),
|
||||||
|
E.source(file=volume),
|
||||||
|
E.target(dev='vda', bus='virtio'),
|
||||||
|
type='file',
|
||||||
|
device='disk',
|
||||||
|
),
|
||||||
|
E.interface(
|
||||||
|
E.source(network='default'),
|
||||||
|
E.mac(address=mac_addr),
|
||||||
|
type='network',
|
||||||
|
),
|
||||||
|
E.graphics(
|
||||||
|
E.listen(type='address'),
|
||||||
|
type='vnc', port='-1', autoport='yes'
|
||||||
|
),
|
||||||
|
E.video(
|
||||||
|
E.model(type='vga', vram='16384', heads='1', primary='yes'),
|
||||||
|
E.address(type='pci', domain='0x0000', bus='0x00',
|
||||||
|
slot='0x02', function='0x0'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
type='kvm',
|
||||||
|
)
|
||||||
|
return self.to_string(domain_xml)
|
||||||
|
|
||||||
|
def gen_volume_xml(self,
|
||||||
|
device_name: str,
|
||||||
|
file: Path,
|
||||||
|
bus: str = 'virtio',
|
||||||
|
cache: str = 'writethrough',
|
||||||
|
disktype: str = 'file'):
|
||||||
|
disk_xml = E.disk(E.driver(name='qemu', type='qcow2', cache=cache),
|
||||||
|
E.source(file=file),
|
||||||
|
E.target(dev=device_name, bus=bus),
|
||||||
|
type=disktype,
|
||||||
|
device='disk')
|
||||||
|
return self.to_string(disk_xml)
|
||||||
|
|
||||||
|
def add_volume(self):
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
def add_meta(self, data: dict, namespace: str, nsprefix: str) -> None:
|
||||||
|
"""
|
||||||
|
Add metadata to domain. See:
|
||||||
|
https://libvirt.org/formatdomain.html#general-metadata
|
||||||
|
"""
|
||||||
|
metadata = metadata_old = self.xml.xpath('/domain/metadata')[0]
|
||||||
|
metadata.append(
|
||||||
|
self.construct_xml(
|
||||||
|
data,
|
||||||
|
namespace=namespace,
|
||||||
|
nsprefix=nsprefix,
|
||||||
|
))
|
||||||
|
self.xml.replace(metadata_old, metadata)
|
||||||
|
|
||||||
|
def remove_meta(self, namespace: str):
|
||||||
|
"""Remove metadata by namespace."""
|
||||||
|
raise NotImplementedError()
|
||||||
|
|
||||||
|
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 to_string(self):
|
||||||
|
return (tostring(self.xml, pretty_print=True,
|
||||||
|
encoding='utf-8').decode().strip())
|
@ -1,54 +1,21 @@
|
|||||||
from pathlib import Path
|
from pathlib import Path
|
||||||
|
|
||||||
from lxml.builder import E
|
from lxml.builder import E
|
||||||
from lxml.etree import Element, QName, SubElement, tostring
|
from lxml.etree import Element, QName, SubElement, tostring, fromstring
|
||||||
|
|
||||||
from .mac import random_mac
|
|
||||||
|
|
||||||
|
|
||||||
XPATH_DOMAIN_NAME = '/domain/name'
|
class Constructor:
|
||||||
XPATH_DOMAIN_TITLE = '/domain/title'
|
|
||||||
XPATH_DOMAIN_DESCRIPTION = '/domain/description'
|
|
||||||
XPATH_DOMAIN_METADATA = '/domain/metadata'
|
|
||||||
XPATH_DOMAIN_MEMORY = '/domain/memory'
|
|
||||||
XPATH_DOMAIN_CURRENT_MEMORY = '/domain/currentMemory'
|
|
||||||
XPATH_DOMAIN_VCPU = '/domain/vcpu'
|
|
||||||
XPATH_DOMAIN_OS = '/domian/os'
|
|
||||||
XPATH_DOMAIN_CPU = '/domain/cpu'
|
|
||||||
|
|
||||||
|
|
||||||
class XMLConstructor:
|
|
||||||
"""
|
"""
|
||||||
The XML constructor. This class builds XML configs for libvirtd.
|
The XML constructor. This class builds XML configs for libvirt.
|
||||||
Features:
|
|
||||||
- Generate basic virtual machine XML. See gen_domain_xml()
|
|
||||||
- Generate virtual disk XML. See gen_volume_xml()
|
|
||||||
- Add arbitrary metadata to XML from special structured dict
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, xml: str | None = None):
|
def gen_domain_xml(self, name: str, title: str, desc: str, memory: int,
|
||||||
self.xml_string = xml
|
vcpus: int, domain_type: str, machine: str, arch: str,
|
||||||
self.xml = None
|
boot_order: tuple, cpu: str, mac: str) -> str:
|
||||||
|
|
||||||
@property
|
|
||||||
def domain_xml(self):
|
|
||||||
return self.xml
|
|
||||||
|
|
||||||
def gen_domain_xml(self,
|
|
||||||
name: str,
|
|
||||||
title: str,
|
|
||||||
vcpus: int,
|
|
||||||
vcpu_vendor: str,
|
|
||||||
vcpu_model: str,
|
|
||||||
memory: int,
|
|
||||||
volume: Path,
|
|
||||||
vcpu_features: dict | None = None,
|
|
||||||
desc: str = "") -> None:
|
|
||||||
"""
|
"""
|
||||||
Generate default domain XML configuration for virtual machines.
|
Return basic libvirt domain configuration.
|
||||||
See https://lxml.de/tutorial.html#the-e-factory for details.
|
|
||||||
"""
|
"""
|
||||||
self.xml = E.domain(
|
domain = E.domain(
|
||||||
E.name(name),
|
E.name(name),
|
||||||
E.title(title),
|
E.title(title),
|
||||||
E.description(desc),
|
E.description(desc),
|
||||||
@ -56,142 +23,55 @@ class XMLConstructor:
|
|||||||
E.memory(str(memory), unit='MB'),
|
E.memory(str(memory), unit='MB'),
|
||||||
E.currentMemory(str(memory), unit='MB'),
|
E.currentMemory(str(memory), unit='MB'),
|
||||||
E.vcpu(str(vcpus), placement='static'),
|
E.vcpu(str(vcpus), placement='static'),
|
||||||
E.os(
|
type='kvm'
|
||||||
E.type('hvm', arch='x86_64'),
|
|
||||||
E.boot(dev='cdrom'),
|
|
||||||
E.boot(dev='hd'),
|
|
||||||
),
|
|
||||||
E.features(
|
|
||||||
E.acpi(),
|
|
||||||
E.apic(),
|
|
||||||
),
|
|
||||||
E.cpu(
|
|
||||||
E.vendor(vcpu_vendor),
|
|
||||||
E.model(vcpu_model, fallback='forbid'),
|
|
||||||
E.topology(sockets='1', dies='1', cores=str(vcpus),
|
|
||||||
threads='1'),
|
|
||||||
mode='custom',
|
|
||||||
match='exact',
|
|
||||||
check='partial',
|
|
||||||
),
|
|
||||||
E.on_poweroff('destroy'),
|
|
||||||
E.on_reboot('restart'),
|
|
||||||
E.on_crash('restart'),
|
|
||||||
E.pm(
|
|
||||||
E('suspend-to-mem', enabled='no'),
|
|
||||||
E('suspend-to-disk', enabled='no'),
|
|
||||||
),
|
|
||||||
E.devices(
|
|
||||||
E.emulator('/usr/bin/qemu-system-x86_64'),
|
|
||||||
E.disk(
|
|
||||||
E.driver(name='qemu', type='qcow2', cache='writethrough'),
|
|
||||||
E.source(file=volume),
|
|
||||||
E.target(dev='vda', bus='virtio'),
|
|
||||||
type='file',
|
|
||||||
device='disk',
|
|
||||||
),
|
|
||||||
E.interface(
|
|
||||||
E.source(network='default'),
|
|
||||||
E.mac(address=random_mac()),
|
|
||||||
type='network',
|
|
||||||
),
|
|
||||||
E.graphics(
|
|
||||||
E.listen(type='address'),
|
|
||||||
type='vnc', port='-1', autoport='yes'
|
|
||||||
),
|
|
||||||
E.video(
|
|
||||||
E.model(type='vga', vram='16384', heads='1', primary='yes'),
|
|
||||||
E.address(type='pci', domain='0x0000', bus='0x00',
|
|
||||||
slot='0x02', function='0x0'),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
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,
|
def gen_volume_xml(self, dev: str, mode: str) -> str:
|
||||||
device_name: str,
|
|
||||||
file: Path,
|
|
||||||
bus: str = 'virtio',
|
|
||||||
cache: str = 'writethrough',
|
|
||||||
disktype: str = 'file'):
|
|
||||||
return E.disk(E.driver(name='qemu', type='qcow2', cache=cache),
|
|
||||||
E.source(file=file),
|
|
||||||
E.target(dev=device_name, bus=bus),
|
|
||||||
type=disktype,
|
|
||||||
device='disk')
|
|
||||||
|
|
||||||
def add_volume(self):
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
def add_meta(self, data: dict, namespace: str, nsprefix: str) -> None:
|
|
||||||
"""
|
"""
|
||||||
Add metadata to domain. See:
|
Todo: No hardcode
|
||||||
https://libvirt.org/formatdomain.html#general-metadata
|
https://libvirt.org/formatdomain.html#hard-drives-floppy-disks-cdroms
|
||||||
"""
|
"""
|
||||||
metadata = metadata_old = self.xml.xpath('/domain/metadata')[0]
|
volume = E.disk(type='file', device='disk')
|
||||||
metadata.append(
|
volume.append(E.driver(name='qemu', type='qcow2', cache='writethrough'))
|
||||||
self.construct_xml(
|
volume.append(E.source(file=path))
|
||||||
data,
|
volume.append(E.target(dev=dev, bus='virtio'))
|
||||||
namespace=namespace,
|
if mode.lower() == 'ro':
|
||||||
nsprefix=nsprefix,
|
volume.append(E.readonly())
|
||||||
))
|
return tostring(volume, encoding='unicode', pretty_print=True).strip()
|
||||||
self.xml.replace(metadata_old, metadata)
|
|
||||||
|
|
||||||
def remove_meta(self, namespace: str):
|
|
||||||
"""Remove metadata by namespace."""
|
|
||||||
raise NotImplementedError()
|
|
||||||
|
|
||||||
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': 'devices', # 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 to_string(self):
|
|
||||||
return (tostring(self.xml, pretty_print=True,
|
|
||||||
encoding='utf-8').decode().strip())
|
|
||||||
|
@ -1,3 +1,5 @@
|
|||||||
from .exceptions import *
|
from .exceptions import *
|
||||||
from .ga import QemuAgent
|
from .guest_agent import QemuAgent
|
||||||
from .main import VirtualMachine
|
from .virtual_machine import VirtualMachine
|
||||||
|
from .installer import VirtualMachineInstaller
|
||||||
|
from .hardware import vCPUMode, vCPUTopology
|
||||||
|
@ -1,22 +1,31 @@
|
|||||||
import libvirt
|
import libvirt
|
||||||
|
|
||||||
from .exceptions import VMError, VMNotFound
|
from .exceptions import VMError
|
||||||
|
|
||||||
|
|
||||||
class VirtualMachineBase:
|
class VirtualMachineBase:
|
||||||
|
|
||||||
def __init__(self, session: 'LibvirtSession', name: str):
|
def __init__(self, domain: libvirt.virDomain):
|
||||||
self.domname = name
|
self.domain = domain
|
||||||
self.session = session.session # virConnect object
|
self.domain_name = self._get_domain_name()
|
||||||
self.config = session.config # ConfigLoader object
|
self.domain_info = self._get_domain_info()
|
||||||
self.domain = self._get_domain(name)
|
|
||||||
|
|
||||||
def _get_domain(self, name: str) -> libvirt.virDomain:
|
def _get_domain_name(self):
|
||||||
"""Get virDomain object by name to manipulate with domain."""
|
|
||||||
try:
|
try:
|
||||||
domain = self.session.lookupByName(name)
|
return self.domain.name()
|
||||||
if domain is not None:
|
|
||||||
return domain
|
|
||||||
raise VMNotFound(name)
|
|
||||||
except libvirt.libvirtError as err:
|
except libvirt.libvirtError as err:
|
||||||
raise VMError(err) from err
|
raise VMError(f'Cannot get domain name: {err}') from err
|
||||||
|
|
||||||
|
def _get_domain_info(self):
|
||||||
|
# https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainInfo
|
||||||
|
try:
|
||||||
|
info = self.domain.info()
|
||||||
|
return {
|
||||||
|
'state': info[0],
|
||||||
|
'max_memory': info[1],
|
||||||
|
'memory': info[2],
|
||||||
|
'nproc': info[3],
|
||||||
|
'cputime': info[4]
|
||||||
|
}
|
||||||
|
except libvirt.libvirtError as err:
|
||||||
|
raise VMError(f'Cannot get domain info: {err}') from err
|
||||||
|
@ -8,7 +8,7 @@ class VMError(Exception):
|
|||||||
|
|
||||||
class VMNotFound(Exception):
|
class VMNotFound(Exception):
|
||||||
|
|
||||||
def __init__(self, domain, message='VM not found: {domain}'):
|
def __init__(self, domain, message='VM not found vm={domain}'):
|
||||||
self.domain = domain
|
self.domain = domain
|
||||||
self.message = message.format(domain=domain)
|
self.message = message.format(domain=domain)
|
||||||
super().__init__(self.message)
|
super().__init__(self.message)
|
||||||
|
@ -12,8 +12,9 @@ from .exceptions import QemuAgentError
|
|||||||
|
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
QEMU_TIMEOUT = 60 # seconds
|
# Note that if no QEMU_TIMEOUT libvirt cannot connect to agent
|
||||||
POLL_INTERVAL = 0.3 # also seconds
|
QEMU_TIMEOUT = 60 # in seconds
|
||||||
|
POLL_INTERVAL = 0.3 # also in seconds
|
||||||
|
|
||||||
|
|
||||||
class QemuAgent(VirtualMachineBase):
|
class QemuAgent(VirtualMachineBase):
|
||||||
@ -28,12 +29,9 @@ class QemuAgent(VirtualMachineBase):
|
|||||||
must be passed as string. Wraps execute() method.
|
must be passed as string. Wraps execute() method.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self,
|
def __init__(self, domain: libvirt.virDomain, timeout: int | None = None,
|
||||||
session: 'LibvirtSession',
|
|
||||||
name: str,
|
|
||||||
timeout: int | None = None,
|
|
||||||
flags: int | None = None):
|
flags: int | None = None):
|
||||||
super().__init__(session, name)
|
super().__init__(domain)
|
||||||
self.timeout = timeout or QEMU_TIMEOUT # timeout for guest agent
|
self.timeout = timeout or QEMU_TIMEOUT # timeout for guest agent
|
||||||
self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
|
self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
|
||||||
|
|
||||||
@ -110,7 +108,11 @@ class QemuAgent(VirtualMachineBase):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def _execute(self, command: dict):
|
def _execute(self, command: dict):
|
||||||
logging.debug('Execute command: vm=%s cmd=%s', self.domname, command)
|
logging.debug('Execute command: vm=%s cmd=%s', self.domain_name,
|
||||||
|
command)
|
||||||
|
if self.domain_info['state'] != libvirt.VIR_DOMAIN_RUNNING:
|
||||||
|
raise GuestAgentError(
|
||||||
|
f'Cannot execute command: vm={self.domain_name} is not running')
|
||||||
try:
|
try:
|
||||||
return libvirt_qemu.qemuAgentCommand(
|
return libvirt_qemu.qemuAgentCommand(
|
||||||
self.domain, # virDomain object
|
self.domain, # virDomain object
|
||||||
@ -119,7 +121,9 @@ class QemuAgent(VirtualMachineBase):
|
|||||||
self.flags,
|
self.flags,
|
||||||
)
|
)
|
||||||
except libvirt.libvirtError as err:
|
except libvirt.libvirtError as err:
|
||||||
raise QemuAgentError(err) from err
|
raise QemuAgentError(
|
||||||
|
f'Cannot execute command on vm={self.domain_name}: {err}'
|
||||||
|
) from err
|
||||||
|
|
||||||
def _get_cmd_result(
|
def _get_cmd_result(
|
||||||
self, pid: int, decode_output: bool = False, wait: bool = True,
|
self, pid: int, decode_output: bool = False, wait: bool = True,
|
||||||
@ -131,7 +135,8 @@ class QemuAgent(VirtualMachineBase):
|
|||||||
output = json.loads(self._execute(cmd))
|
output = json.loads(self._execute(cmd))
|
||||||
return self._return_tuple(output, decode=decode_output)
|
return self._return_tuple(output, decode=decode_output)
|
||||||
|
|
||||||
logger.debug('Start polling command pid=%s', pid)
|
logger.debug('Start polling command pid=%s on vm=%s', pid,
|
||||||
|
self.domain_name)
|
||||||
start_time = time()
|
start_time = time()
|
||||||
while True:
|
while True:
|
||||||
output = json.loads(self._execute(cmd))
|
output = json.loads(self._execute(cmd))
|
||||||
@ -141,10 +146,12 @@ class QemuAgent(VirtualMachineBase):
|
|||||||
now = time()
|
now = time()
|
||||||
if now - start_time > timeout:
|
if now - start_time > timeout:
|
||||||
raise QemuAgentError(
|
raise QemuAgentError(
|
||||||
f'Polling command pid={pid} took longer than {timeout} seconds.'
|
f'Polling command pid={pid} on vm={self.domain_name} '
|
||||||
|
f'took longer than {timeout} seconds.'
|
||||||
)
|
)
|
||||||
logger.debug('Polling command pid=%s finished, time taken: %s seconds',
|
logger.debug('Polling command pid=%s on vm=%s finished, '
|
||||||
pid, int(time() - start_time))
|
'time taken: %s seconds',
|
||||||
|
pid, self.domain_name, int(time() - start_time))
|
||||||
return self._return_tuple(output, decode=decode_output)
|
return self._return_tuple(output, decode=decode_output)
|
||||||
|
|
||||||
def _return_tuple(self, output: dict, decode: bool = False):
|
def _return_tuple(self, output: dict, decode: bool = False):
|
81
node_agent/vm/hardware.py
Normal file
81
node_agent/vm/hardware.py
Normal file
@ -0,0 +1,81 @@
|
|||||||
|
import textwrap
|
||||||
|
from enum import Enum
|
||||||
|
from collections import UserDict
|
||||||
|
|
||||||
|
import libvirt
|
||||||
|
from lxml.etree import SubElement, fromstring, tostring
|
||||||
|
|
||||||
|
|
||||||
|
class Boot(Enum):
|
||||||
|
BIOS = 'bios'
|
||||||
|
UEFI = 'uefi'
|
||||||
|
|
||||||
|
|
||||||
|
class vCPUMode(Enum):
|
||||||
|
HOST_MODEL = 'host-model'
|
||||||
|
HOST_PASSTHROUGTH = 'host-passthrougth'
|
||||||
|
CUSTOM = 'custom'
|
||||||
|
MAXIMUM = 'maximum'
|
||||||
|
|
||||||
|
|
||||||
|
class DomainCapabilities:
|
||||||
|
|
||||||
|
def __init__(self, session: libvirt.virConnect):
|
||||||
|
self.session = session
|
||||||
|
self.domcaps = fromstring(
|
||||||
|
self.session.getDomainCapabilities())
|
||||||
|
|
||||||
|
@property
|
||||||
|
def arch(self):
|
||||||
|
return self.domcaps.xpath('/domainCapabilities/arch')[0].text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def virttype(self):
|
||||||
|
return self.domcaps.xpath('/domainCapabilities/domain')[0].text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def emulator(self):
|
||||||
|
return self.domcaps.xpath('/domainCapabilities/path')[0].text
|
||||||
|
|
||||||
|
@property
|
||||||
|
def machine(self):
|
||||||
|
return self.domcaps.xpath('/domainCapabilities/machine')[0].text
|
||||||
|
|
||||||
|
def best_cpu(self, mode: vCPUMode) -> str:
|
||||||
|
"""
|
||||||
|
See https://libvirt.org/html/libvirt-libvirt-host.html
|
||||||
|
#virConnectBaselineHypervisorCPU
|
||||||
|
"""
|
||||||
|
cpus = self.domcaps.xpath(
|
||||||
|
f'/domainCapabilities/cpu/mode[@name="{mode}"]')[0]
|
||||||
|
cpus.tag = 'cpu'
|
||||||
|
for attr in cpus.attrib.keys():
|
||||||
|
del cpus.attrib[attr]
|
||||||
|
arch = SubElement(cpus, 'arch')
|
||||||
|
arch.text = self.arch
|
||||||
|
xmlcpus = tostring(cpus, encoding='unicode', pretty_print=True)
|
||||||
|
xml = self.session.baselineHypervisorCPU(self.emulator,
|
||||||
|
self.arch, self.machine, self.virttype, [xmlcpus])
|
||||||
|
return textwrap.indent(xml, ' ' * 2)
|
||||||
|
|
||||||
|
|
||||||
|
class vCPUTopology(UserDict):
|
||||||
|
"""
|
||||||
|
CPU topology schema ``{'sockets': 1, 'cores': 4, 'threads': 1}``::
|
||||||
|
|
||||||
|
<topology sockets='1' dies='1' cores='4' threads='1'/>
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, topology: dict):
|
||||||
|
super().__init__(self._validate(topology))
|
||||||
|
|
||||||
|
def _validate(self, topology: dict):
|
||||||
|
if isinstance(topology, dict):
|
||||||
|
if ['sockets', 'cores', 'threads'] != list(topology.keys()):
|
||||||
|
raise ValueError("Topology must have 'sockets', 'cores' "
|
||||||
|
"and 'threads' keys.")
|
||||||
|
for key in topology.keys():
|
||||||
|
if not isinstance(topology[key], int):
|
||||||
|
raise TypeError(f"Key '{key}' must be 'int'")
|
||||||
|
return topology
|
||||||
|
raise TypeError("Topology must be a 'dict'")
|
94
node_agent/vm/installer.py
Normal file
94
node_agent/vm/installer.py
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
import libvirt
|
||||||
|
|
||||||
|
from ..utils.xml import Constructor
|
||||||
|
from ..utils.mac import random_mac
|
||||||
|
from .hardware import DomainCapabilities, vCPUMode, vCPUTopology, Boot
|
||||||
|
|
||||||
|
|
||||||
|
class vCPUInfo:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class ImageVolume:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class CloudInitConfig:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class BootOrder:
|
||||||
|
pass
|
||||||
|
|
||||||
|
class VirtualMachineInstaller:
|
||||||
|
def __init__(self, session: libvirt.virConnect):
|
||||||
|
self.session = session
|
||||||
|
self.info = {}
|
||||||
|
|
||||||
|
def install(
|
||||||
|
self,
|
||||||
|
name: str | None = None,
|
||||||
|
title: str | None = None,
|
||||||
|
description: str = '',
|
||||||
|
os: str | None = None,
|
||||||
|
image: ImageVolume | None = None,
|
||||||
|
volumes: list['VolumeInfo'] | None = None,
|
||||||
|
vcpus: int = 0,
|
||||||
|
vcpu_info: vCPUInfo | None = None,
|
||||||
|
vcpu_mode: vCPUMode | None = None,
|
||||||
|
vcpu_topology: vCPUTopology | None = None,
|
||||||
|
memory: int = 0,
|
||||||
|
boot: Boot = Boot.BIOS,
|
||||||
|
boot_menu: bool = False,
|
||||||
|
boot_order: BootOrder = ('cdrom', 'hd'),
|
||||||
|
cloud_init: CloudInitConfig | None = None):
|
||||||
|
"""
|
||||||
|
Install virtual machine with passed parameters.
|
||||||
|
"""
|
||||||
|
domcaps = DomainCapabilities(self.session.session)
|
||||||
|
name = self._validate_name(name)
|
||||||
|
if vcpu_topology is None:
|
||||||
|
vcpu_topology = vCPUTopology(
|
||||||
|
{'sockets': 1, 'cores': vcpus, 'threads': 1})
|
||||||
|
self._validate_topology(vcpus, vcpu_topology)
|
||||||
|
if vcpu_info is None:
|
||||||
|
if not vcpu_mode:
|
||||||
|
vcpu_mode = vCPUMode.CUSTOM.value
|
||||||
|
xml_cpu = domcaps.best_cpu(vcpu_mode)
|
||||||
|
else:
|
||||||
|
raise NotImplementedError('Custom CPU not implemented')
|
||||||
|
xml_domain = Constructor().gen_domain_xml(
|
||||||
|
name=name,
|
||||||
|
title=title if title else name,
|
||||||
|
desc=description if description else '',
|
||||||
|
vcpus=vcpus,
|
||||||
|
memory=memory,
|
||||||
|
domain_type='hvm',
|
||||||
|
machine=domcaps.machine,
|
||||||
|
arch=domcaps.arch,
|
||||||
|
boot_order=('cdrom', 'hd'),
|
||||||
|
cpu=xml_cpu,
|
||||||
|
mac=random_mac()
|
||||||
|
)
|
||||||
|
return xml_domain
|
||||||
|
|
||||||
|
def _validate_name(self, name):
|
||||||
|
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 _validate_topology(self, vcpus, topology):
|
||||||
|
sockets = topology['sockets']
|
||||||
|
cores = topology['cores']
|
||||||
|
threads = topology['threads']
|
||||||
|
if sockets * cores * threads == vcpus:
|
||||||
|
return
|
||||||
|
raise ValueError("CPU topology must match the number of 'vcpus'")
|
||||||
|
|
||||||
|
def _define(self, xml: str):
|
||||||
|
self.session.defineXML(xml)
|
@ -13,7 +13,7 @@ class VirtualMachine(VirtualMachineBase):
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def name(self):
|
def name(self):
|
||||||
return self.domname
|
return self.domain_name
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def status(self) -> str:
|
def status(self) -> str:
|
||||||
@ -27,7 +27,7 @@ class VirtualMachine(VirtualMachineBase):
|
|||||||
state = self.domain.state()[0]
|
state = self.domain.state()[0]
|
||||||
except libvirt.libvirtError as err:
|
except libvirt.libvirtError as err:
|
||||||
raise VMError(
|
raise VMError(
|
||||||
f'Cannot fetch VM status vm={self.domname}: {err}') from err
|
f'Cannot fetch VM status vm={self.domain_name}: {err}') from err
|
||||||
STATES = {
|
STATES = {
|
||||||
libvirt.VIR_DOMAIN_NOSTATE: 'nostate',
|
libvirt.VIR_DOMAIN_NOSTATE: 'nostate',
|
||||||
libvirt.VIR_DOMAIN_RUNNING: 'running',
|
libvirt.VIR_DOMAIN_RUNNING: 'running',
|
||||||
@ -57,20 +57,21 @@ class VirtualMachine(VirtualMachineBase):
|
|||||||
return False
|
return False
|
||||||
except libvirt.libvirtError as err:
|
except libvirt.libvirtError as err:
|
||||||
raise VMError(
|
raise VMError(
|
||||||
f'Cannot get autostart status vm={self.domname}: {err}'
|
f'Cannot get autostart status vm={self.domain_name}: {err}'
|
||||||
) from err
|
) from err
|
||||||
|
|
||||||
def start(self) -> None:
|
def start(self) -> None:
|
||||||
"""Start defined VM."""
|
"""Start defined VM."""
|
||||||
logger.info('Starting VM: vm=%s', self.domname)
|
logger.info('Starting VM: vm=%s', self.domain_name)
|
||||||
if self.is_running:
|
if self.is_running:
|
||||||
logger.debug('VM vm=%s is already started, nothing to do',
|
logger.warning('VM vm=%s is already started, nothing to do',
|
||||||
self.domname)
|
self.domain_name)
|
||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
self.domain.create()
|
self.domain.create()
|
||||||
except libvirt.libvirtError as err:
|
except libvirt.libvirtError as err:
|
||||||
raise VMError(f'Cannot start vm={self.domname}: {err}') from err
|
raise VMError(
|
||||||
|
f'Cannot start vm={self.domain_name}: {err}') from err
|
||||||
|
|
||||||
def shutdown(self, mode: str | None = None) -> None:
|
def shutdown(self, mode: str | None = None) -> None:
|
||||||
"""
|
"""
|
||||||
@ -78,7 +79,7 @@ class VirtualMachine(VirtualMachineBase):
|
|||||||
* GUEST_AGENT - use guest agent
|
* GUEST_AGENT - use guest agent
|
||||||
* NORMAL - use method choosen by hypervisor to shutdown machine
|
* NORMAL - use method choosen by hypervisor to shutdown machine
|
||||||
* SIGTERM - send SIGTERM to QEMU process, destroy machine gracefully
|
* SIGTERM - send SIGTERM to QEMU process, destroy machine gracefully
|
||||||
* SIGKILL - send SIGKILL, this option may corrupt guest data!
|
* SIGKILL - send SIGKILL to QEMU process. May corrupt guest data!
|
||||||
If mode is not passed use 'NORMAL' mode.
|
If mode is not passed use 'NORMAL' mode.
|
||||||
"""
|
"""
|
||||||
MODES = {
|
MODES = {
|
||||||
@ -90,7 +91,7 @@ class VirtualMachine(VirtualMachineBase):
|
|||||||
if mode is None:
|
if mode is None:
|
||||||
mode = 'NORMAL'
|
mode = 'NORMAL'
|
||||||
if not isinstance(mode, str):
|
if not isinstance(mode, str):
|
||||||
raise ValueError(f'Mode must be a string, not {type(mode)}')
|
raise ValueError(f"Mode must be a 'str', not {type(mode)}")
|
||||||
if mode.upper() not in MODES:
|
if mode.upper() not in MODES:
|
||||||
raise ValueError(f"Unsupported mode: '{mode}'")
|
raise ValueError(f"Unsupported mode: '{mode}'")
|
||||||
try:
|
try:
|
||||||
@ -99,7 +100,7 @@ class VirtualMachine(VirtualMachineBase):
|
|||||||
elif mode in ['SIGTERM', 'SIGKILL']:
|
elif mode in ['SIGTERM', 'SIGKILL']:
|
||||||
self.domain.destroyFlags(flags=MODES.get(mode))
|
self.domain.destroyFlags(flags=MODES.get(mode))
|
||||||
except libvirt.libvirtError as err:
|
except libvirt.libvirtError as err:
|
||||||
raise VMError(f'Cannot shutdown vm={self.domname} with '
|
raise VMError(f'Cannot shutdown vm={self.domain_name} with '
|
||||||
f'mode={mode}: {err}') from err
|
f'mode={mode}: {err}') from err
|
||||||
|
|
||||||
def reset(self) -> None:
|
def reset(self) -> None:
|
||||||
@ -116,16 +117,18 @@ class VirtualMachine(VirtualMachineBase):
|
|||||||
try:
|
try:
|
||||||
self.domain.reset()
|
self.domain.reset()
|
||||||
except libvirt.libvirtError as err:
|
except libvirt.libvirtError as err:
|
||||||
raise VMError(f'Cannot reset vm={self.domname}: {err}') from err
|
raise VMError(
|
||||||
|
f'Cannot reset vm={self.domain_name}: {err}') from err
|
||||||
|
|
||||||
def reboot(self) -> None:
|
def reboot(self) -> None:
|
||||||
"""Send ACPI signal to guest OS to reboot. OS may ignore this."""
|
"""Send ACPI signal to guest OS to reboot. OS may ignore this."""
|
||||||
try:
|
try:
|
||||||
self.domain.reboot()
|
self.domain.reboot()
|
||||||
except libvirt.libvirtError as err:
|
except libvirt.libvirtError as err:
|
||||||
raise VMError(f'Cannot reboot vm={self.domname}: {err}') from err
|
raise VMError(
|
||||||
|
f'Cannot reboot vm={self.domain_name}: {err}') from err
|
||||||
|
|
||||||
def autostart(self, enable: bool) -> None:
|
def set_autostart(self, enable: bool) -> None:
|
||||||
"""
|
"""
|
||||||
Configure VM to be automatically started when the host machine boots.
|
Configure VM to be automatically started when the host machine boots.
|
||||||
"""
|
"""
|
||||||
@ -136,13 +139,57 @@ class VirtualMachine(VirtualMachineBase):
|
|||||||
try:
|
try:
|
||||||
self.domain.setAutostart(autostart_flag)
|
self.domain.setAutostart(autostart_flag)
|
||||||
except libvirt.libvirtError as err:
|
except libvirt.libvirtError as err:
|
||||||
raise VMError(f'Cannot set autostart vm={self.domname} '
|
raise VMError(f'Cannot set autostart vm={self.domain_name} '
|
||||||
f'autostart={autostart_flag}: {err}') from err
|
f'autostart={autostart_flag}: {err}') from err
|
||||||
|
|
||||||
def set_vcpus(self, count: int):
|
def set_vcpus(self, nvcpus: int, hotplug: bool = False):
|
||||||
|
"""
|
||||||
|
Set vCPUs for VM. If `hotplug` is True set vCPUs on running VM.
|
||||||
|
If VM is not running set `hotplug` to False. If `hotplug` is True
|
||||||
|
and VM is not currently running vCPUs will set in config and will
|
||||||
|
applied when machine boot.
|
||||||
|
|
||||||
|
NB: Note that if this call is executed before the guest has
|
||||||
|
finished booting, the guest may fail to process the change.
|
||||||
|
"""
|
||||||
|
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 +
|
||||||
|
libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||||
|
else:
|
||||||
|
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||||
|
try:
|
||||||
|
self.domain.setVcpusFlags(nvcpus, flags=flags)
|
||||||
|
except libvirt.libvirtError as err:
|
||||||
|
raise VMError(
|
||||||
|
f'Cannot set vCPUs for vm={self.domain_name}: {err}') from err
|
||||||
|
|
||||||
|
def set_memory(self, memory: int, hotplug: bool = False):
|
||||||
|
"""
|
||||||
|
Set momory for VM. `memory` must be passed in mebibytes. Internally
|
||||||
|
converted to kibibytes. If `hotplug` is True set memory for running
|
||||||
|
VM, else set memory in config and will applied when machine boot.
|
||||||
|
If `hotplug` is True and machine is not currently running set memory
|
||||||
|
in config.
|
||||||
|
"""
|
||||||
|
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 +
|
||||||
|
libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||||
|
else:
|
||||||
|
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||||
|
try:
|
||||||
|
self.domain.setVcpusFlags(memory * 1024, flags=flags)
|
||||||
|
except libvirt.libvirtError as err:
|
||||||
|
raise VMError(
|
||||||
|
f'Cannot set memory for vm={self.domain_name}: {err}') from err
|
||||||
|
|
||||||
|
def attach_device(self, device: str):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def set_ram(self, count: int):
|
def detach_device(self, device: str):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
def list_ssh_keys(self, user: str):
|
def list_ssh_keys(self, user: str):
|
||||||
@ -156,3 +203,11 @@ class VirtualMachine(VirtualMachineBase):
|
|||||||
|
|
||||||
def set_user_password(self, user: str):
|
def set_user_password(self, user: str):
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
def dump_xml(self) -> str:
|
||||||
|
return self.domain.XMLDesc()
|
||||||
|
|
||||||
|
def delete(self, delete_volumes: bool = False):
|
||||||
|
"""Undefine VM."""
|
||||||
|
self.shutdown(method='SIGTERM')
|
||||||
|
self.domain.undefine()
|
9
node_agent/volume/storage_pool.py
Normal file
9
node_agent/volume/storage_pool.py
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import libvirt
|
||||||
|
|
||||||
|
|
||||||
|
class StoragePool:
|
||||||
|
def __init__(self, pool: libvirt.virStoragePool):
|
||||||
|
self.pool = pool
|
||||||
|
|
||||||
|
def create_volume(self):
|
||||||
|
pass
|
23
node_agent/volume/volume.py
Normal file
23
node_agent/volume/volume.py
Normal file
@ -0,0 +1,23 @@
|
|||||||
|
import libvirt
|
||||||
|
|
||||||
|
|
||||||
|
class VolumeInfo:
|
||||||
|
"""
|
||||||
|
Volume info schema
|
||||||
|
{'type': 'local', 'system': True, 'size': 102400, 'mode': 'rw'}
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
class Volume:
|
||||||
|
def __init__(self, pool: libvirt.virStorageVol):
|
||||||
|
self.pool = pool
|
||||||
|
|
||||||
|
def lookup_by_path(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def generate_xml(self):
|
||||||
|
pass
|
||||||
|
|
||||||
|
def create(self):
|
||||||
|
pass
|
Loading…
Reference in New Issue
Block a user