various improvements
This commit is contained in:
		
							
								
								
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							
							
						
						
									
										7
									
								
								.gitignore
									
									
									
									
										vendored
									
									
								
							@@ -1,8 +1,7 @@
 | 
				
			|||||||
 | 
					dist/
 | 
				
			||||||
 | 
					docs/build/
 | 
				
			||||||
 | 
					.ruff_cache/
 | 
				
			||||||
__pycache__/
 | 
					__pycache__/
 | 
				
			||||||
*.pyc
 | 
					*.pyc
 | 
				
			||||||
*~
 | 
					*~
 | 
				
			||||||
dom*
 | 
					 | 
				
			||||||
na
 | 
					 | 
				
			||||||
dist/
 | 
					 | 
				
			||||||
P@ssw0rd
 | 
					 | 
				
			||||||
*.todo
 | 
					*.todo
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										34
									
								
								Makefile
									
									
									
									
									
								
							
							
						
						
									
										34
									
								
								Makefile
									
									
									
									
									
								
							@@ -1,17 +1,33 @@
 | 
				
			|||||||
SRC = computelib/
 | 
					SRC = compute/
 | 
				
			||||||
 | 
					DIST = dist/
 | 
				
			||||||
 | 
					DOCS_SRC = docs/source/
 | 
				
			||||||
 | 
					DOCS_BUILD = docs/build/
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: docs
 | 
				
			||||||
 | 
					
 | 
				
			||||||
all: build
 | 
					all: build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
build:
 | 
					build: format lint
 | 
				
			||||||
	poetry build
 | 
						poetry build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
clean:
 | 
					 | 
				
			||||||
	[ -d dist/ ] && rm -rf dist/ || true
 | 
					 | 
				
			||||||
	find . -type d -name __pycache__ -exec rm -rf {} \; > /dev/null 2>&1 || true
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
format:
 | 
					format:
 | 
				
			||||||
	isort --lai 2 $(SRC)
 | 
						poetry run isort --lai 2 $(SRC)
 | 
				
			||||||
	autopep8 -riva --experimental --ignore e255 $(SRC)
 | 
						poetry run ruff format $(SRC)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
lint:
 | 
					lint:
 | 
				
			||||||
	pylint $(SRC)
 | 
						poetry run ruff check $(SRC)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					docs:
 | 
				
			||||||
 | 
						poetry run sphinx-build $(DOCS_SRC) $(DOCS_BUILD)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					serve-docs:
 | 
				
			||||||
 | 
						poetry run sphinx-autobuild $(DOCS_SRC) $(DOCS_BUILD)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					clean:
 | 
				
			||||||
 | 
						[ -d $(DIST) ] && rm -rf $(DIST) || true
 | 
				
			||||||
 | 
						[ -d $(DOCS_BUILD) ] && rm -rf $(DOCS_BUILD) || true
 | 
				
			||||||
 | 
						find . -type d -name __pycache__ -exec rm -rf {} \; > /dev/null 2>&1 || true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					test-build:
 | 
				
			||||||
 | 
						poetry build
 | 
				
			||||||
 | 
						scp $(DIST)/*.tar.gz vm:~
 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										95
									
								
								README.md
									
									
									
									
									
								
							
							
						
						
									
										95
									
								
								README.md
									
									
									
									
									
								
							@@ -1,8 +1,8 @@
 | 
				
			|||||||
# Compute Node Agent library
 | 
					# Compute Service
 | 
				
			||||||
 | 
					
 | 
				
			||||||
В этом репозитории развивается базовая библиотека для взаимодействия с libvirt и выполнения операций с виртуальными машинами. Фокус на QEMU/KVM.
 | 
					В этом репозитории развивается базовая библиотека для взаимодействия с libvirt и выполнения операций с виртуальными машинами. Фокус на QEMU/KVM.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Зависимости (версии из APT репозитория Debian 12):
 | 
					## Зависимости (версии из репозитория Debian 12):
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- `python3-lxml` 4.9.2
 | 
					- `python3-lxml` 4.9.2
 | 
				
			||||||
- `python3-docopt` 0.6.2
 | 
					- `python3-docopt` 0.6.2
 | 
				
			||||||
@@ -10,71 +10,40 @@
 | 
				
			|||||||
 | 
					
 | 
				
			||||||
Минимальная поддерживаемая версия Python — `3.11`, потому, что можем.
 | 
					Минимальная поддерживаемая версия Python — `3.11`, потому, что можем.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# Утилиты
 | 
					## API
 | 
				
			||||||
 | 
					 | 
				
			||||||
- `na-vmctl` virsh на минималках. Выполняет базовые операции с VM, установку и миграцию и т.п.
 | 
					 | 
				
			||||||
- `na-vmexec`. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна.
 | 
					 | 
				
			||||||
- `na-volctl`. Предполагается здесь оставить всю работу с дисками. Не реализована.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# API
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Кодовая база растёт, необходимо автоматически генерировать документацию, в README её больше не будет.
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
В структуре проекта сейчас бардак, многое будет переосмыслено и переделано позже. Основная цель на текущем этапе — получить минимально работающий код, с помощью которого возможно выполнить установку виртуальной машины и как-то управлять ею.
 | 
					В структуре проекта сейчас бардак, многое будет переосмыслено и переделано позже. Основная цель на текущем этапе — получить минимально работающий код, с помощью которого возможно выполнить установку виртуальной машины и как-то управлять ею.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
Есть набор классов, предоставляющих собой интерфейсы для взаимодействия с виртуальными машинами, стораджами, дисками и т.п. Датаклассы описывают сущности и имеют метод `to_xml()` для получения XML конфига для `libvirt`. Смысл датакласса в том, чтобы иметь один объект, содержащий в себе нормальные легкочитаемые аттрибуты и XML описание сущности одновременно.
 | 
					Есть набор классов, предоставляющих собой интерфейсы для взаимодействия с виртуальными машинами, стораджами, дисками и т.п. Датаклассы описывают сущности и имеют метод `to_xml()` для получения XML конфига для `libvirt`. Смысл использования датаклассов в том, чтобы иметь один объект, содержащий в себе нормальные легкочитаемые аттрибуты и XML описание сущности одновременно.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
# TODO
 | 
					## ROADMAP
 | 
				
			||||||
 | 
					
 | 
				
			||||||
- [x] Установка ВМ (всратый вариант)
 | 
					- [ ] Установка инстансов
 | 
				
			||||||
    - [x] Конструктор XML (базовый)
 | 
					    - [ ] Установка с использованием эталонного образа ОС
 | 
				
			||||||
    - [x] Автоматический выбор модели процессора
 | 
					    - [ ] Установка с пустым диском и загрузкой с ISO
 | 
				
			||||||
    - [x] Метод создания дисков
 | 
					    - [ ] Установка с использованием готового волюма
 | 
				
			||||||
    - [x] Дефайн, запуск и автостарт ВМ
 | 
					- [x] Базовое управление питанием
 | 
				
			||||||
    - [x] Работа со StoragePool
 | 
					- [ ] Остановка и возобновление инстансов
 | 
				
			||||||
    - [x] Создание блочных устройств
 | 
					- [ ] Изменение числа vCPU на горячую
 | 
				
			||||||
    - [x] Подключение/отключение устройств
 | 
					- [ ] Изменение топологии процессора
 | 
				
			||||||
    - [x] Метод install()
 | 
					- [ ] Выбор типа эмуляции процессора, вендора, модели и инструкций
 | 
				
			||||||
    - [ ] Выбор между SeaBIOS/UEFI
 | 
					- [ ] Изменение памяти на горячую
 | 
				
			||||||
    - [ ] Выбор модели процессора
 | 
					- [ ] Ресайз дисков на горячую
 | 
				
			||||||
- [ ] Установка ВМ (нормальный вариант)
 | 
					- [ ] Выбор между BIOS и UEFI
 | 
				
			||||||
- [x] Управление дисками
 | 
					- [ ] Редактирование параметров загрузки (boot menu, etc)
 | 
				
			||||||
    - [x] Локальные qcow2
 | 
					- [x] Горячее подключение устройств
 | 
				
			||||||
 | 
					- [ ] Горячее отключение устройств
 | 
				
			||||||
 | 
					- [ ] GPU
 | 
				
			||||||
 | 
					- [ ] Поддержка инстансов с разной гарантированной долей CPU
 | 
				
			||||||
 | 
					- [x] Базовое управление QEMU Guest Agent
 | 
				
			||||||
 | 
					- [ ] Проверка доступности и возможностей QEMU Guest Agent
 | 
				
			||||||
 | 
					- [ ] Статистика потребления ресурсов
 | 
				
			||||||
 | 
					- [ ] Управление SSH-ключами
 | 
				
			||||||
 | 
					- [ ] Изменение пароля root
 | 
				
			||||||
 | 
					- [ ] LXC
 | 
				
			||||||
 | 
					- [ ] Работа с дисками QCOW2,3
 | 
				
			||||||
- [ ] ZVOL
 | 
					- [ ] ZVOL
 | 
				
			||||||
- [ ] Сетевые диски
 | 
					- [ ] Сетевые диски
 | 
				
			||||||
    - [ ] Живой ресайз файловой системы (?)
 | 
					- [ ] Создание Storage Pool на основе TOML/YAML описания
 | 
				
			||||||
- [x] Удаление ВМ
 | 
					- [ ] Удаление Storage Pool
 | 
				
			||||||
- [x] Изменение CPU
 | 
					- [ ] Снапшоты
 | 
				
			||||||
    - [ ] Полноценный hotplug
 | 
					 | 
				
			||||||
- [x] Изменение RAM
 | 
					 | 
				
			||||||
    - [ ] Полноценный hotplug
 | 
					 | 
				
			||||||
- [ ] Миграция ВМ между нодами
 | 
					 | 
				
			||||||
- [x] Работа с qemu-ga
 | 
					 | 
				
			||||||
- [x] Управление питанием
 | 
					 | 
				
			||||||
- [x] Вкл/выкл автостарт ВМ
 | 
					 | 
				
			||||||
- [ ] Статистика потребления ресурсов
 | 
					 | 
				
			||||||
- [ ] Получение инфомрации из/о ВМ
 | 
					 | 
				
			||||||
- [ ] SSH-ключи
 | 
					 | 
				
			||||||
- [ ] Сеть
 | 
					 | 
				
			||||||
- [ ] Создание снапшотов
 | 
					 | 
				
			||||||
- [ ] Поддержка выделения гарантированной доли CPU
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# Заметки
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Что там с LXC?
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Можно добавить поддержку LXC, но только после реализации основного функционала для QEMU/KVM.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Будущее этой библиотеки
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Нужно задействовать билиотеку [libosinfo](https://libosinfo.org/) для получения информации об операционных системах. См. [How to populate Libosinfo DataBase](https://wiki.libvirt.org/HowToPopulateLibosinfoDB.html).
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
### Failover
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
В перспективе для ВМ с сетевыми дисками возможно организовать Failover решение — ВМ будет автоматически запускаться на другой ноде из пула при отключении оригинальной ноды. Технически это можно реализовать как создание ВМ с аналогичными характеристиками на другой ноде с подключением к тому же самому сетевому диску. Сразу нужно отметить, что для реализации:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
- Нужно где-то хранить и регулярно обновлять информацию о конфигурации ВМ для воссоздания ВМ
 | 
					 | 
				
			||||||
- Нужно иметь "плавающие адреса", чтобы переключить трафик на новую ноду
 | 
					 | 
				
			||||||
- Необходимо выполнять failover по чётким критериям: нода полностью недоступна более X времени, маунт сетевого диска отвалился и т.п.
 | 
					 | 
				
			||||||
- Как быть с целостностью данных на сетевом диске? При аварии на ноде, данные могли быть повреждены, тогда failover на тот же диск ничего не даст.
 | 
					 | 
				
			||||||
- Сетевой диск должен быть зарезервирован средствами распределённой ФС
 | 
					 | 
				
			||||||
 
 | 
				
			|||||||
							
								
								
									
										7
									
								
								compute/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7
									
								
								compute/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,7 @@
 | 
				
			|||||||
 | 
					"""Compute Service library."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__version__ = '0.1.0'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .instance import Instance, InstanceConfig, InstanceSchema
 | 
				
			||||||
 | 
					from .session import Session
 | 
				
			||||||
 | 
					from .storage import StoragePool, Volume, VolumeConfig
 | 
				
			||||||
							
								
								
									
										6
									
								
								compute/__main__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										6
									
								
								compute/__main__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,6 @@
 | 
				
			|||||||
 | 
					"""Command line interface for compute module."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from compute.cli import control
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					control.cli()
 | 
				
			||||||
							
								
								
									
										26
									
								
								compute/cli/_create.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										26
									
								
								compute/cli/_create.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,26 @@
 | 
				
			|||||||
 | 
					import argparse
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from compute import Session
 | 
				
			||||||
 | 
					from compute.utils import identifiers
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _create_instance(session: Session, args: argparse.Namespace) -> None:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Умолчания (достать информацию из либвирта):
 | 
				
			||||||
 | 
					    - arch
 | 
				
			||||||
 | 
					    - machine
 | 
				
			||||||
 | 
					    - emulator
 | 
				
			||||||
 | 
					    - CPU
 | 
				
			||||||
 | 
					        - cpu_vendor
 | 
				
			||||||
 | 
					        - cpu_model
 | 
				
			||||||
 | 
					        - фичи
 | 
				
			||||||
 | 
					    - max_memory
 | 
				
			||||||
 | 
					    - max_vcpus
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    (сегнерировать):
 | 
				
			||||||
 | 
					    - MAC адрес
 | 
				
			||||||
 | 
					    - boot_order = ('cdrom', 'hd')
 | 
				
			||||||
 | 
					    - title = ''
 | 
				
			||||||
 | 
					    - name = uuid.uuid4().hex
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    print(args)
 | 
				
			||||||
							
								
								
									
										319
									
								
								compute/cli/control.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										319
									
								
								compute/cli/control.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,319 @@
 | 
				
			|||||||
 | 
					"""Command line interface."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import argparse
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					import shlex
 | 
				
			||||||
 | 
					import sys
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from compute import __version__
 | 
				
			||||||
 | 
					from compute.exceptions import (
 | 
				
			||||||
 | 
					    ComputeServiceError,
 | 
				
			||||||
 | 
					    GuestAgentTimeoutExceededError,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from compute.instance import GuestAgent
 | 
				
			||||||
 | 
					from compute.session import Session
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from ._create import _create_instance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					log = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					log_levels = logging.getLevelNamesMapping()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					env_log_level = os.getenv('CMP_LOG')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					libvirt.registerErrorHandler(
 | 
				
			||||||
 | 
					    lambda userdata, err: None,  # noqa: ARG005
 | 
				
			||||||
 | 
					    ctx=None,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Table:
 | 
				
			||||||
 | 
					    """Minimalistic text table constructor."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, whitespace: str | None = None):
 | 
				
			||||||
 | 
					        """Initialise Table."""
 | 
				
			||||||
 | 
					        self.whitespace = whitespace or '\t'
 | 
				
			||||||
 | 
					        self.header = []
 | 
				
			||||||
 | 
					        self._rows = []
 | 
				
			||||||
 | 
					        self._table = ''
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def row(self, row: list) -> None:
 | 
				
			||||||
 | 
					        """Add table row."""
 | 
				
			||||||
 | 
					        self._rows.append([str(col) for col in row])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def rows(self, rows: list[list]) -> None:
 | 
				
			||||||
 | 
					        """Add multiple rows."""
 | 
				
			||||||
 | 
					        for row in rows:
 | 
				
			||||||
 | 
					            self.row(row)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __str__(self) -> str:
 | 
				
			||||||
 | 
					        """Build table and return."""
 | 
				
			||||||
 | 
					        widths = [max(map(len, col)) for col in zip(*self._rows, strict=True)]
 | 
				
			||||||
 | 
					        self._rows.insert(0, [str(h).upper() for h in self.header])
 | 
				
			||||||
 | 
					        for row in self._rows:
 | 
				
			||||||
 | 
					            self._table += self.whitespace.join(
 | 
				
			||||||
 | 
					                (
 | 
				
			||||||
 | 
					                    val.ljust(width)
 | 
				
			||||||
 | 
					                    for val, width in zip(row, widths, strict=True)
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            self._table += '\n'
 | 
				
			||||||
 | 
					        return self._table.strip()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _list_instances(session: Session) -> None:
 | 
				
			||||||
 | 
					    table = Table()
 | 
				
			||||||
 | 
					    table.header = ['NAME', 'STATE']
 | 
				
			||||||
 | 
					    for instance in session.list_instances():
 | 
				
			||||||
 | 
					        table.row(
 | 
				
			||||||
 | 
					            [
 | 
				
			||||||
 | 
					                instance.name,
 | 
				
			||||||
 | 
					                instance.status,
 | 
				
			||||||
 | 
					            ]
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    print(table)
 | 
				
			||||||
 | 
					    sys.exit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def _exec_guest_agent_command(
 | 
				
			||||||
 | 
					    session: Session, args: argparse.Namespace
 | 
				
			||||||
 | 
					) -> None:
 | 
				
			||||||
 | 
					    instance = session.get_instance(args.instance)
 | 
				
			||||||
 | 
					    ga = GuestAgent(instance.domain, timeout=args.timeout)
 | 
				
			||||||
 | 
					    arguments = args.arguments.copy()
 | 
				
			||||||
 | 
					    if len(arguments) > 1:
 | 
				
			||||||
 | 
					        arguments = [shlex.join(arguments)]
 | 
				
			||||||
 | 
					    if not args.no_cmd_string:
 | 
				
			||||||
 | 
					        arguments.insert(0, '-c')
 | 
				
			||||||
 | 
					    stdin = None
 | 
				
			||||||
 | 
					    if not sys.stdin.isatty():
 | 
				
			||||||
 | 
					        stdin = sys.stdin.read()
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        output = ga.guest_exec(
 | 
				
			||||||
 | 
					            path=args.shell,
 | 
				
			||||||
 | 
					            args=arguments,
 | 
				
			||||||
 | 
					            env=args.env,
 | 
				
			||||||
 | 
					            stdin=stdin,
 | 
				
			||||||
 | 
					            capture_output=True,
 | 
				
			||||||
 | 
					            decode_output=True,
 | 
				
			||||||
 | 
					            poll=True,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    except GuestAgentTimeoutExceededError as e:
 | 
				
			||||||
 | 
					        sys.exit(
 | 
				
			||||||
 | 
					            f'{e}. NOTE: command may still running in guest, '
 | 
				
			||||||
 | 
					            f'PID={ga.last_pid}'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					    if output.stderr:
 | 
				
			||||||
 | 
					        print(output.stderr.strip(), file=sys.stderr)
 | 
				
			||||||
 | 
					    if output.stdout:
 | 
				
			||||||
 | 
					        print(output.stdout.strip(), file=sys.stdout)
 | 
				
			||||||
 | 
					    sys.exit(output.exitcode)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def main(session: Session, args: argparse.Namespace) -> None:
 | 
				
			||||||
 | 
					    """Perform actions."""
 | 
				
			||||||
 | 
					    match args.command:
 | 
				
			||||||
 | 
					        case 'create':
 | 
				
			||||||
 | 
					            _create_instance(session, args)
 | 
				
			||||||
 | 
					        case 'exec':
 | 
				
			||||||
 | 
					            _exec_guest_agent_command(session, args)
 | 
				
			||||||
 | 
					        case 'ls':
 | 
				
			||||||
 | 
					            _list_instances(session)
 | 
				
			||||||
 | 
					        case 'start':
 | 
				
			||||||
 | 
					            instance = session.get_instance(args.instance)
 | 
				
			||||||
 | 
					            instance.start()
 | 
				
			||||||
 | 
					        case 'shutdown':
 | 
				
			||||||
 | 
					            instance = session.get_instance(args.instance)
 | 
				
			||||||
 | 
					            instance.shutdown(args.method)
 | 
				
			||||||
 | 
					        case 'reboot':
 | 
				
			||||||
 | 
					            instance = session.get_instance(args.instance)
 | 
				
			||||||
 | 
					            instance.reboot()
 | 
				
			||||||
 | 
					        case 'reset':
 | 
				
			||||||
 | 
					            instance = session.get_instance(args.instance)
 | 
				
			||||||
 | 
					            instance.reset()
 | 
				
			||||||
 | 
					        case 'status':
 | 
				
			||||||
 | 
					            instance = session.get_instance(args.instance)
 | 
				
			||||||
 | 
					            print(instance.status)
 | 
				
			||||||
 | 
					        case 'setvcpus':
 | 
				
			||||||
 | 
					            instance = session.get_instance(args.instance)
 | 
				
			||||||
 | 
					            instance.set_vcpus(args.nvcpus, live=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def cli() -> None:  # noqa: PLR0915
 | 
				
			||||||
 | 
					    """Parse command line arguments."""
 | 
				
			||||||
 | 
					    root = argparse.ArgumentParser(
 | 
				
			||||||
 | 
					        prog='compute',
 | 
				
			||||||
 | 
					        description='manage compute instances and storage volumes.',
 | 
				
			||||||
 | 
					        formatter_class=argparse.RawTextHelpFormatter,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    root.add_argument(
 | 
				
			||||||
 | 
					        '-v',
 | 
				
			||||||
 | 
					        '--verbose',
 | 
				
			||||||
 | 
					        action='store_true',
 | 
				
			||||||
 | 
					        default=False,
 | 
				
			||||||
 | 
					        help='enable verbose mode',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    root.add_argument(
 | 
				
			||||||
 | 
					        '-c',
 | 
				
			||||||
 | 
					        '--connect',
 | 
				
			||||||
 | 
					        metavar='URI',
 | 
				
			||||||
 | 
					        default='qemu:///system',
 | 
				
			||||||
 | 
					        help='libvirt connection URI',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    root.add_argument(
 | 
				
			||||||
 | 
					        '-l',
 | 
				
			||||||
 | 
					        '--log-level',
 | 
				
			||||||
 | 
					        metavar='LEVEL',
 | 
				
			||||||
 | 
					        choices=log_levels,
 | 
				
			||||||
 | 
					        help='log level [envvar: CMP_LOG]',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    root.add_argument(
 | 
				
			||||||
 | 
					        '-V',
 | 
				
			||||||
 | 
					        '--version',
 | 
				
			||||||
 | 
					        action='version',
 | 
				
			||||||
 | 
					        version=__version__,
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # create command
 | 
				
			||||||
 | 
					    create = subparsers.add_parser('create', help='create compute instance')
 | 
				
			||||||
 | 
					    create.add_argument('image', nargs='?')
 | 
				
			||||||
 | 
					    create.add_argument('--name', help='instance name, used as ID')
 | 
				
			||||||
 | 
					    create.add_argument('--title', help='human-understandable instance title')
 | 
				
			||||||
 | 
					    create.add_argument('--desc', default='', help='instance description')
 | 
				
			||||||
 | 
					    create.add_argument('--memory', type=int, help='memory in MiB')
 | 
				
			||||||
 | 
					    create.add_argument('--max-memory', type=int, help='max memory in MiB')
 | 
				
			||||||
 | 
					    create.add_argument('--vcpus', type=int)
 | 
				
			||||||
 | 
					    create.add_argument('--max-vcpus', type=int)
 | 
				
			||||||
 | 
					    create.add_argument('--cpu-vendor')
 | 
				
			||||||
 | 
					    create.add_argument('--cpu-model')
 | 
				
			||||||
 | 
					    create.add_argument(
 | 
				
			||||||
 | 
					        '--cpu-emulation-mode',
 | 
				
			||||||
 | 
					        choices=['host-passthrough', 'host-model', 'custom'],
 | 
				
			||||||
 | 
					        default='host-passthrough',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    create.add_argument('--cpu-features')
 | 
				
			||||||
 | 
					    create.add_argument('--cpu-topology')
 | 
				
			||||||
 | 
					    create.add_argument('--mahine')
 | 
				
			||||||
 | 
					    create.add_argument('--emulator')
 | 
				
			||||||
 | 
					    create.add_argument('--arch')
 | 
				
			||||||
 | 
					    create.add_argument('--boot-order')
 | 
				
			||||||
 | 
					    create.add_argument('--volume')
 | 
				
			||||||
 | 
					    create.add_argument('-f', '--file', help='create instance from YAML')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # exec subcommand
 | 
				
			||||||
 | 
					    execute = subparsers.add_parser(
 | 
				
			||||||
 | 
					        'exec',
 | 
				
			||||||
 | 
					        help='execute command in guest via guest agent',
 | 
				
			||||||
 | 
					        description=(
 | 
				
			||||||
 | 
					            'NOTE: any argument after instance name will be passed into '
 | 
				
			||||||
 | 
					            'guest as shell command.'
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    execute.add_argument('instance')
 | 
				
			||||||
 | 
					    execute.add_argument('arguments', nargs=argparse.REMAINDER)
 | 
				
			||||||
 | 
					    execute.add_argument(
 | 
				
			||||||
 | 
					        '-t',
 | 
				
			||||||
 | 
					        '--timeout',
 | 
				
			||||||
 | 
					        type=int,
 | 
				
			||||||
 | 
					        default=60,
 | 
				
			||||||
 | 
					        help=(
 | 
				
			||||||
 | 
					            'waiting time in seconds for a command to be executed '
 | 
				
			||||||
 | 
					            'in guest, 60 sec by default'
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    execute.add_argument(
 | 
				
			||||||
 | 
					        '-s',
 | 
				
			||||||
 | 
					        '--shell',
 | 
				
			||||||
 | 
					        default='/bin/sh',
 | 
				
			||||||
 | 
					        help='path to executable in guest, /bin/sh by default',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    execute.add_argument(
 | 
				
			||||||
 | 
					        '-e',
 | 
				
			||||||
 | 
					        '--env',
 | 
				
			||||||
 | 
					        type=str,
 | 
				
			||||||
 | 
					        nargs='?',
 | 
				
			||||||
 | 
					        action='append',
 | 
				
			||||||
 | 
					        help='environment variables to pass to executable in guest',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					    execute.add_argument(
 | 
				
			||||||
 | 
					        '-n',
 | 
				
			||||||
 | 
					        '--no-cmd-string',
 | 
				
			||||||
 | 
					        action='store_true',
 | 
				
			||||||
 | 
					        default=False,
 | 
				
			||||||
 | 
					        help=(
 | 
				
			||||||
 | 
					            "do not append '-c' option to arguments list, suitable "
 | 
				
			||||||
 | 
					            'for non-shell executables and other specific cases.'
 | 
				
			||||||
 | 
					        ),
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # ls subcommand
 | 
				
			||||||
 | 
					    listall = subparsers.add_parser('ls', help='list instances')
 | 
				
			||||||
 | 
					    listall.add_argument(
 | 
				
			||||||
 | 
					        '-a',
 | 
				
			||||||
 | 
					        '--all',
 | 
				
			||||||
 | 
					        action='store_true',
 | 
				
			||||||
 | 
					        default=False,
 | 
				
			||||||
 | 
					        help='list all instances including inactive',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # start subcommand
 | 
				
			||||||
 | 
					    start = subparsers.add_parser('start', help='start instance')
 | 
				
			||||||
 | 
					    start.add_argument('instance')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # shutdown subcommand
 | 
				
			||||||
 | 
					    shutdown = subparsers.add_parser('shutdown', help='shutdown instance')
 | 
				
			||||||
 | 
					    shutdown.add_argument('instance')
 | 
				
			||||||
 | 
					    shutdown.add_argument(
 | 
				
			||||||
 | 
					        '-m',
 | 
				
			||||||
 | 
					        '--method',
 | 
				
			||||||
 | 
					        choices=['soft', 'normal', 'hard', 'unsafe'],
 | 
				
			||||||
 | 
					        default='normal',
 | 
				
			||||||
 | 
					        help='use shutdown method',
 | 
				
			||||||
 | 
					    )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # reboot subcommand
 | 
				
			||||||
 | 
					    reboot = subparsers.add_parser('reboot', help='reboot instance')
 | 
				
			||||||
 | 
					    reboot.add_argument('instance')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # reset subcommand
 | 
				
			||||||
 | 
					    reset = subparsers.add_parser('reset', help='reset instance')
 | 
				
			||||||
 | 
					    reset.add_argument('instance')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # status subcommand
 | 
				
			||||||
 | 
					    status = subparsers.add_parser('status', help='display instance status')
 | 
				
			||||||
 | 
					    status.add_argument('instance')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # setvcpus subcommand
 | 
				
			||||||
 | 
					    setvcpus = subparsers.add_parser('setvcpus', help='set vCPU number')
 | 
				
			||||||
 | 
					    setvcpus.add_argument('instance')
 | 
				
			||||||
 | 
					    setvcpus.add_argument('nvcpus', type=int)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Run parser
 | 
				
			||||||
 | 
					    args = root.parse_args()
 | 
				
			||||||
 | 
					    if args.command is None:
 | 
				
			||||||
 | 
					        root.print_help()
 | 
				
			||||||
 | 
					        sys.exit()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Set logging level
 | 
				
			||||||
 | 
					    log_level = args.log_level or env_log_level
 | 
				
			||||||
 | 
					    if log_level in log_levels:
 | 
				
			||||||
 | 
					        logging.basicConfig(level=log_levels[log_level])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    # Perform actions
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        with Session(args.connect) as session:
 | 
				
			||||||
 | 
					            main(session, args)
 | 
				
			||||||
 | 
					    except ComputeServiceError as e:
 | 
				
			||||||
 | 
					        sys.exit(f'error: {e}')
 | 
				
			||||||
 | 
					    except (KeyboardInterrupt, SystemExit):
 | 
				
			||||||
 | 
					        sys.exit()
 | 
				
			||||||
 | 
					    except Exception as e:  # noqa: BLE001
 | 
				
			||||||
 | 
					        sys.exit(f'unexpected error {type(e)}: {e}')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if __name__ == '__main__':
 | 
				
			||||||
 | 
					    cli()
 | 
				
			||||||
							
								
								
									
										49
									
								
								compute/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										49
									
								
								compute/exceptions.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,49 @@
 | 
				
			|||||||
 | 
					"""Compute Service exceptions."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ComputeServiceError(Exception):
 | 
				
			||||||
 | 
					    """Basic exception class for Compute."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ConfigLoaderError(ComputeServiceError):
 | 
				
			||||||
 | 
					    """Something went wrong when loading configuration."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class SessionError(ComputeServiceError):
 | 
				
			||||||
 | 
					    """Something went wrong while connecting to libvirtd."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class GuestAgentError(ComputeServiceError):
 | 
				
			||||||
 | 
					    """Something went wring when QEMU Guest Agent call."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class GuestAgentUnavailableError(GuestAgentError):
 | 
				
			||||||
 | 
					    """Guest agent is not connected or is unavailable."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class GuestAgentTimeoutExceededError(GuestAgentError):
 | 
				
			||||||
 | 
					    """QEMU timeout exceeded."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, msg: int):
 | 
				
			||||||
 | 
					        """Initialise GuestAgentTimeoutExceededError."""
 | 
				
			||||||
 | 
					        super().__init__(f'QEMU timeout ({msg} sec) exceeded')
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class GuestAgentCommandNotSupportedError(GuestAgentError):
 | 
				
			||||||
 | 
					    """Guest agent command is not supported or blacklisted on guest."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StoragePoolError(ComputeServiceError):
 | 
				
			||||||
 | 
					    """Something went wrong when operating with storage pool."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstanceError(ComputeServiceError):
 | 
				
			||||||
 | 
					    """Something went wrong while interacting with the domain."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstanceNotFoundError(InstanceError):
 | 
				
			||||||
 | 
					    """Virtual machine or container not found on compute node."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, msg: str):
 | 
				
			||||||
 | 
					        """Initialise InstanceNotFoundError."""
 | 
				
			||||||
 | 
					        super().__init__(f"compute instance '{msg}' not found")
 | 
				
			||||||
							
								
								
									
										3
									
								
								compute/instance/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								compute/instance/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
				
			|||||||
 | 
					from .guest_agent import GuestAgent
 | 
				
			||||||
 | 
					from .instance import Instance, InstanceConfig
 | 
				
			||||||
 | 
					from .schemas import InstanceSchema
 | 
				
			||||||
							
								
								
									
										197
									
								
								compute/instance/guest_agent.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										197
									
								
								compute/instance/guest_agent.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,197 @@
 | 
				
			|||||||
 | 
					"""Manage QEMU guest agent."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import json
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from base64 import b64decode, standard_b64encode
 | 
				
			||||||
 | 
					from time import sleep, time
 | 
				
			||||||
 | 
					from typing import NamedTuple
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					import libvirt_qemu
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from compute.exceptions import (
 | 
				
			||||||
 | 
					    GuestAgentCommandNotSupportedError,
 | 
				
			||||||
 | 
					    GuestAgentError,
 | 
				
			||||||
 | 
					    GuestAgentTimeoutExceededError,
 | 
				
			||||||
 | 
					    GuestAgentUnavailableError,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					log = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					QEMU_TIMEOUT = 60
 | 
				
			||||||
 | 
					POLL_INTERVAL = 0.3
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class GuestExecOutput(NamedTuple):
 | 
				
			||||||
 | 
					    """QEMU guest-exec command output."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    exited: bool | None = None
 | 
				
			||||||
 | 
					    exitcode: int | None = None
 | 
				
			||||||
 | 
					    stdout: str | None = None
 | 
				
			||||||
 | 
					    stderr: str | None = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class GuestAgent:
 | 
				
			||||||
 | 
					    """Class for interacting with QEMU guest agent."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, domain: libvirt.virDomain, timeout: int | None = None):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Initialise GuestAgent.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param domain: Libvirt domain object
 | 
				
			||||||
 | 
					        :param timeout: QEMU timeout
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.domain = domain
 | 
				
			||||||
 | 
					        self.timeout = timeout or QEMU_TIMEOUT
 | 
				
			||||||
 | 
					        self.flags = libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
 | 
				
			||||||
 | 
					        self.last_pid = None
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def execute(self, command: dict) -> dict:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Execute QEMU guest agent command.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        See: https://qemu-project.gitlab.io/qemu/interop/qemu-ga-ref.html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param command: QEMU guest agent command as dict
 | 
				
			||||||
 | 
					        :return: Command output
 | 
				
			||||||
 | 
					        :rtype: dict
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        log.debug(command)
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            output = libvirt_qemu.qemuAgentCommand(
 | 
				
			||||||
 | 
					                self.domain, json.dumps(command), self.timeout, self.flags
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return json.loads(output)
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            if e.get_error_code() == libvirt.VIR_ERR_AGENT_UNRESPONSIVE:
 | 
				
			||||||
 | 
					                log.exception(
 | 
				
			||||||
 | 
					                    'Guest agent is unavailable on instanse=%s', self.name
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                raise GuestAgentUnavailableError(e) from e
 | 
				
			||||||
 | 
					            raise GuestAgentError(e) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def is_available(self) -> bool:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Execute guest-ping.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :return: True or False if guest agent is unreachable.
 | 
				
			||||||
 | 
					        :rtype: bool
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            if self.execute({'execute': 'guest-ping', 'arguments': {}}):
 | 
				
			||||||
 | 
					                return True
 | 
				
			||||||
 | 
					        except GuestAgentError:
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def available_commands(self) -> set[str]:
 | 
				
			||||||
 | 
					        """Return set of available guest agent commands."""
 | 
				
			||||||
 | 
					        output = self.execute({'execute': 'guest-info', 'arguments': {}})
 | 
				
			||||||
 | 
					        return {
 | 
				
			||||||
 | 
					            cmd['name']
 | 
				
			||||||
 | 
					            for cmd in output['return']['supported_commands']
 | 
				
			||||||
 | 
					            if cmd['enabled'] is True
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def raise_for_commands(self, commands: list[str]) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Check QEMU guest agent command availability.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Raise exception if command is not available.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param commands: List of required commands
 | 
				
			||||||
 | 
					        :raise: GuestAgentCommandNotSupportedError
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        for command in commands:
 | 
				
			||||||
 | 
					            if command not in self.available_commands():
 | 
				
			||||||
 | 
					                raise GuestAgentCommandNotSupportedError(command)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def guest_exec(  # noqa: PLR0913
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        path: str,
 | 
				
			||||||
 | 
					        args: list[str] | None = None,
 | 
				
			||||||
 | 
					        env: list[str] | None = None,
 | 
				
			||||||
 | 
					        stdin: str | None = None,
 | 
				
			||||||
 | 
					        *,
 | 
				
			||||||
 | 
					        capture_output: bool = False,
 | 
				
			||||||
 | 
					        decode_output: bool = False,
 | 
				
			||||||
 | 
					        poll: bool = False,
 | 
				
			||||||
 | 
					    ) -> GuestExecOutput:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Execute qemu-exec command and return output.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param path: Path ot executable on guest.
 | 
				
			||||||
 | 
					        :param arg: List of arguments to pass to executable.
 | 
				
			||||||
 | 
					        :param env: List of environment variables to pass to executable.
 | 
				
			||||||
 | 
					            For example: ``['LANG=C', 'TERM=xterm']``
 | 
				
			||||||
 | 
					        :param stdin: Data to pass to executable STDIN.
 | 
				
			||||||
 | 
					        :param capture_output: Capture command output.
 | 
				
			||||||
 | 
					        :param decode_output: Use base64_decode() to decode command output.
 | 
				
			||||||
 | 
					            Affects only if `capture_output` is True.
 | 
				
			||||||
 | 
					        :param poll: Poll command output. Uses `self.timeout` and
 | 
				
			||||||
 | 
					            POLL_INTERVAL constant.
 | 
				
			||||||
 | 
					        :return: Command output
 | 
				
			||||||
 | 
					        :rtype: GuestExecOutput
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.raise_for_commands(['guest-exec', 'guest-exec-status'])
 | 
				
			||||||
 | 
					        command = {
 | 
				
			||||||
 | 
					            'execute': 'guest-exec',
 | 
				
			||||||
 | 
					            'arguments': {
 | 
				
			||||||
 | 
					                'path': path,
 | 
				
			||||||
 | 
					                **({'arg': args} if args else {}),
 | 
				
			||||||
 | 
					                **({'env': env} if env else {}),
 | 
				
			||||||
 | 
					                **(
 | 
				
			||||||
 | 
					                    {
 | 
				
			||||||
 | 
					                        'input-data': standard_b64encode(
 | 
				
			||||||
 | 
					                            stdin.encode('utf-8')
 | 
				
			||||||
 | 
					                        ).decode('utf-8')
 | 
				
			||||||
 | 
					                    }
 | 
				
			||||||
 | 
					                    if stdin
 | 
				
			||||||
 | 
					                    else {}
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                'capture-output': capture_output,
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        output = self.execute(command)
 | 
				
			||||||
 | 
					        self.last_pid = pid = output['return']['pid']
 | 
				
			||||||
 | 
					        command_status = self.guest_exec_status(pid, poll=poll)['return']
 | 
				
			||||||
 | 
					        exited = command_status['exited']
 | 
				
			||||||
 | 
					        exitcode = command_status['exitcode']
 | 
				
			||||||
 | 
					        stdout = command_status.get('out-data', None)
 | 
				
			||||||
 | 
					        stderr = command_status.get('err-data', None)
 | 
				
			||||||
 | 
					        if decode_output:
 | 
				
			||||||
 | 
					            stdout = b64decode(stdout or '').decode('utf-8')
 | 
				
			||||||
 | 
					            stderr = b64decode(stderr or '').decode('utf-8')
 | 
				
			||||||
 | 
					        return GuestExecOutput(exited, exitcode, stdout, stderr)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def guest_exec_status(self, pid: int, *, poll: bool = False) -> dict:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Execute guest-exec-status and return output.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param pid: PID in guest
 | 
				
			||||||
 | 
					        :param poll: If True poll command status with POLL_INTERVAL
 | 
				
			||||||
 | 
					        :return: Command output
 | 
				
			||||||
 | 
					        :rtype: dict
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.raise_for_commands(['guest-exec-status'])
 | 
				
			||||||
 | 
					        command = {
 | 
				
			||||||
 | 
					            'execute': 'guest-exec-status',
 | 
				
			||||||
 | 
					            'arguments': {'pid': pid},
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if not poll:
 | 
				
			||||||
 | 
					            return self.execute(command)
 | 
				
			||||||
 | 
					        start_time = time()
 | 
				
			||||||
 | 
					        while True:
 | 
				
			||||||
 | 
					            command_status = self.execute(command)
 | 
				
			||||||
 | 
					            if command_status['return']['exited']:
 | 
				
			||||||
 | 
					                break
 | 
				
			||||||
 | 
					            sleep(POLL_INTERVAL)
 | 
				
			||||||
 | 
					            now = time()
 | 
				
			||||||
 | 
					            if now - start_time > self.timeout:
 | 
				
			||||||
 | 
					                raise GuestAgentTimeoutExceededError(self.timeout)
 | 
				
			||||||
 | 
					        log.debug(
 | 
				
			||||||
 | 
					            'Polling command pid=%s finished, time taken: %s seconds',
 | 
				
			||||||
 | 
					            pid,
 | 
				
			||||||
 | 
					            int(time() - start_time),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return command_status
 | 
				
			||||||
							
								
								
									
										551
									
								
								compute/instance/instance.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										551
									
								
								compute/instance/instance.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,551 @@
 | 
				
			|||||||
 | 
					"""Manage compute instances."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					__all__ = ['Instance', 'InstanceConfig']
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from dataclasses import dataclass
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					from lxml import etree
 | 
				
			||||||
 | 
					from lxml.builder import E
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from compute.exceptions import (
 | 
				
			||||||
 | 
					    GuestAgentCommandNotSupportedError,
 | 
				
			||||||
 | 
					    InstanceError,
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					from compute.utils import units
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .guest_agent import GuestAgent
 | 
				
			||||||
 | 
					from .schemas import CPUSchema, InstanceSchema, NetworkInterfaceSchema
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					log = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstanceConfig:
 | 
				
			||||||
 | 
					    """Compute instance description for libvirt."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, schema: InstanceSchema):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Initialise InstanceConfig.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param schema: InstanceSchema object
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.name = schema.name
 | 
				
			||||||
 | 
					        self.title = schema.title
 | 
				
			||||||
 | 
					        self.description = schema.description
 | 
				
			||||||
 | 
					        self.memory = schema.memory
 | 
				
			||||||
 | 
					        self.max_memory = schema.max_memory
 | 
				
			||||||
 | 
					        self.vcpus = schema.vcpus
 | 
				
			||||||
 | 
					        self.max_vcpus = schema.max_vcpus
 | 
				
			||||||
 | 
					        self.cpu = schema.cpu
 | 
				
			||||||
 | 
					        self.machine = schema.machine
 | 
				
			||||||
 | 
					        self.emulator = schema.emulator
 | 
				
			||||||
 | 
					        self.arch = schema.arch
 | 
				
			||||||
 | 
					        self.boot = schema.boot
 | 
				
			||||||
 | 
					        self.network_interfaces = schema.network_interfaces
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _gen_cpu_xml(self, cpu: CPUSchema) -> etree.Element:
 | 
				
			||||||
 | 
					        xml = E.cpu(match='exact', mode=cpu.emulation_mode)
 | 
				
			||||||
 | 
					        xml.append(E.model(cpu.model, fallback='forbid'))
 | 
				
			||||||
 | 
					        xml.append(E.vendor(cpu.vendor))
 | 
				
			||||||
 | 
					        xml.append(
 | 
				
			||||||
 | 
					            E.topology(
 | 
				
			||||||
 | 
					                sockets=str(cpu.topology.sockets),
 | 
				
			||||||
 | 
					                dies=str(cpu.topology.dies),
 | 
				
			||||||
 | 
					                cores=str(cpu.topology.cores),
 | 
				
			||||||
 | 
					                threads=str(cpu.topology.threads),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        for feature in cpu.features.require:
 | 
				
			||||||
 | 
					            xml.append(E.feature(policy='require', name=feature))
 | 
				
			||||||
 | 
					        for feature in cpu.features.disable:
 | 
				
			||||||
 | 
					            xml.append(E.feature(policy='disable', name=feature))
 | 
				
			||||||
 | 
					        return xml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _gen_vcpus_xml(self, vcpus: int, max_vcpus: int) -> etree.Element:
 | 
				
			||||||
 | 
					        xml = E.vcpus()
 | 
				
			||||||
 | 
					        xml.append(E.vcpu(id='0', enabled='yes', hotpluggable='no', order='1'))
 | 
				
			||||||
 | 
					        for i in range(max_vcpus - 1):
 | 
				
			||||||
 | 
					            enabled = 'yes' if (i + 2) <= vcpus else 'no'
 | 
				
			||||||
 | 
					            xml.append(
 | 
				
			||||||
 | 
					                E.vcpu(
 | 
				
			||||||
 | 
					                    id=str(i + 1),
 | 
				
			||||||
 | 
					                    enabled=enabled,
 | 
				
			||||||
 | 
					                    hotpluggable='yes',
 | 
				
			||||||
 | 
					                    order=str(i + 2),
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        return xml
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _gen_network_interface_xml(
 | 
				
			||||||
 | 
					        self, interface: NetworkInterfaceSchema
 | 
				
			||||||
 | 
					    ) -> etree.Element:
 | 
				
			||||||
 | 
					        return E.interface(
 | 
				
			||||||
 | 
					            E.source(network=interface.source),
 | 
				
			||||||
 | 
					            E.mac(address=interface.mac),
 | 
				
			||||||
 | 
					            type='network',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_xml(self) -> str:
 | 
				
			||||||
 | 
					        """Return XML config for libvirt."""
 | 
				
			||||||
 | 
					        xml = E.domain(
 | 
				
			||||||
 | 
					            E.name(self.name),
 | 
				
			||||||
 | 
					            E.title(self.title),
 | 
				
			||||||
 | 
					            E.description(self.description),
 | 
				
			||||||
 | 
					            E.metadata(),
 | 
				
			||||||
 | 
					            E.memory(str(self.memory * 1024), unit='KiB'),
 | 
				
			||||||
 | 
					            E.currentMemory(str(self.memory * 1024), unit='KiB'),
 | 
				
			||||||
 | 
					            type='kvm',
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        xml.append(
 | 
				
			||||||
 | 
					            E.vcpu(
 | 
				
			||||||
 | 
					                str(self.max_vcpus),
 | 
				
			||||||
 | 
					                placement='static',
 | 
				
			||||||
 | 
					                current=str(self.vcpus),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        xml.append(self._gen_cpu_xml(self.cpu))
 | 
				
			||||||
 | 
					        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(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(str(self.emulator)))
 | 
				
			||||||
 | 
					        for interface in self.network_interfaces:
 | 
				
			||||||
 | 
					            devices.append(self._gen_network_interface_xml(interface))
 | 
				
			||||||
 | 
					        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)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class InstanceInfo:
 | 
				
			||||||
 | 
					    state: str
 | 
				
			||||||
 | 
					    max_memory: int
 | 
				
			||||||
 | 
					    memory: int
 | 
				
			||||||
 | 
					    nproc: int
 | 
				
			||||||
 | 
					    cputime: int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DeviceConfig:
 | 
				
			||||||
 | 
					    """Abstract device description class."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Instance:
 | 
				
			||||||
 | 
					    """Class for manipulating compute instance."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, domain: libvirt.virDomain):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Initialise Instance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :prop domain libvirt.virDomain:
 | 
				
			||||||
 | 
					        :prop connection libvirt.virConnect:
 | 
				
			||||||
 | 
					        :prop name str:
 | 
				
			||||||
 | 
					        :prop guest_agent GuestAgent:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param domain: libvirt domain object
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.domain = domain
 | 
				
			||||||
 | 
					        self.connection = domain.connect()
 | 
				
			||||||
 | 
					        self.name = domain.name()
 | 
				
			||||||
 | 
					        self.guest_agent = GuestAgent(domain)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def _expand_instance_state(self, state: int) -> str:
 | 
				
			||||||
 | 
					        states = {
 | 
				
			||||||
 | 
					            libvirt.VIR_DOMAIN_NOSTATE: 'nostate',
 | 
				
			||||||
 | 
					            libvirt.VIR_DOMAIN_RUNNING: 'running',
 | 
				
			||||||
 | 
					            libvirt.VIR_DOMAIN_BLOCKED: 'blocked',
 | 
				
			||||||
 | 
					            libvirt.VIR_DOMAIN_PAUSED: 'paused',
 | 
				
			||||||
 | 
					            libvirt.VIR_DOMAIN_SHUTDOWN: 'shutdown',
 | 
				
			||||||
 | 
					            libvirt.VIR_DOMAIN_SHUTOFF: 'shutoff',
 | 
				
			||||||
 | 
					            libvirt.VIR_DOMAIN_CRASHED: 'crashed',
 | 
				
			||||||
 | 
					            libvirt.VIR_DOMAIN_PMSUSPENDED: 'pmsuspended',
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        return states[state]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def info(self) -> InstanceInfo:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Return instance info.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainInfo
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        _info = self.domain.info()
 | 
				
			||||||
 | 
					        return InstanceInfo(
 | 
				
			||||||
 | 
					            state=self._expand_instance_state(_info[0]),
 | 
				
			||||||
 | 
					            max_memory=_info[1],
 | 
				
			||||||
 | 
					            memory=_info[2],
 | 
				
			||||||
 | 
					            nproc=_info[3],
 | 
				
			||||||
 | 
					            cputime=_info[4],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def status(self) -> str:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Return instance state: 'running', 'shutoff', etc.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Reference:
 | 
				
			||||||
 | 
					        https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            state, _ = self.domain.state()
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                'Cannot fetch status of ' f'instance={self.name}: {e}'
 | 
				
			||||||
 | 
					            ) from e
 | 
				
			||||||
 | 
					        return self._expand_instance_state(state)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_running(self) -> bool:
 | 
				
			||||||
 | 
					        """Return True if instance is running, else return False."""
 | 
				
			||||||
 | 
					        if self.domain.isActive() != 1:
 | 
				
			||||||
 | 
					            # 0 - is inactive, -1 - is error
 | 
				
			||||||
 | 
					            return False
 | 
				
			||||||
 | 
					        return True
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def is_autostart(self) -> bool:
 | 
				
			||||||
 | 
					        """Return True if instance autostart is enabled, else return False."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return bool(self.domain.autostart())
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                f'Cannot get autostart status for '
 | 
				
			||||||
 | 
					                f'instance={self.name}: {e}'
 | 
				
			||||||
 | 
					            ) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def start(self) -> None:
 | 
				
			||||||
 | 
					        """Start defined instance."""
 | 
				
			||||||
 | 
					        log.info('Starting instnce=%s', self.name)
 | 
				
			||||||
 | 
					        if self.is_running:
 | 
				
			||||||
 | 
					            log.warning(
 | 
				
			||||||
 | 
					                'Already started, nothing to do instance=%s', self.name
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            self.domain.create()
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                f'Cannot start instance={self.name}: {e}'
 | 
				
			||||||
 | 
					            ) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def shutdown(self, method: str | None = None) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Shutdown instance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Shutdown methods:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        SOFT
 | 
				
			||||||
 | 
					            Use guest agent to shutdown. If guest agent is unavailable
 | 
				
			||||||
 | 
					            NORMAL method will be used.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        NORMAL
 | 
				
			||||||
 | 
					            Use method choosen by hypervisor to shutdown. Usually send ACPI
 | 
				
			||||||
 | 
					            signal to guest OS. OS may ignore ACPI e.g. if guest is hanged.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        HARD
 | 
				
			||||||
 | 
					            Shutdown instance without any guest OS shutdown. This is simular
 | 
				
			||||||
 | 
					            to unplugging machine from power. Internally send SIGTERM to
 | 
				
			||||||
 | 
					            instance process and destroy it gracefully.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        UNSAFE
 | 
				
			||||||
 | 
					            Force shutdown. Internally send SIGKILL to instance process.
 | 
				
			||||||
 | 
					            There is high data corruption risk!
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        If method is None NORMAL method will used.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param method: Method used to shutdown instance
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        methods = {
 | 
				
			||||||
 | 
					            'SOFT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
 | 
				
			||||||
 | 
					            'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT,
 | 
				
			||||||
 | 
					            'HARD': libvirt.VIR_DOMAIN_DESTROY_GRACEFUL,
 | 
				
			||||||
 | 
					            'UNSAFE': libvirt.VIR_DOMAIN_DESTROY_DEFAULT,
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        if method is None:
 | 
				
			||||||
 | 
					            method = 'NORMAL'
 | 
				
			||||||
 | 
					        if not isinstance(method, str):
 | 
				
			||||||
 | 
					            raise TypeError(
 | 
				
			||||||
 | 
					                f"Shutdown method must be a 'str', not {type(method)}"
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        method = method.upper()
 | 
				
			||||||
 | 
					        if method not in methods:
 | 
				
			||||||
 | 
					            raise ValueError(f"Unsupported shutdown method: '{method}'")
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            if method in ['SOFT', 'NORMAL']:
 | 
				
			||||||
 | 
					                self.domain.shutdownFlags(flags=methods[method])
 | 
				
			||||||
 | 
					            elif method in ['HARD', 'UNSAFE']:
 | 
				
			||||||
 | 
					                self.domain.destroyFlags(flags=methods[method])
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                f'Cannot shutdown instance={self.name} ' f'{method=}: {e}'
 | 
				
			||||||
 | 
					            ) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def reset(self) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Reset instance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Copypaste from libvirt doc:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Reset a domain immediately without any guest OS shutdown.
 | 
				
			||||||
 | 
					        Reset emulates the power reset button on a machine, where all
 | 
				
			||||||
 | 
					        hardware sees the RST line set and reinitializes internal state.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        Note that there is a risk of data loss caused by reset without any
 | 
				
			||||||
 | 
					        guest OS shutdown.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            self.domain.reset()
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                f'Cannot reset instance={self.name}: {e}'
 | 
				
			||||||
 | 
					            ) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def reboot(self) -> None:
 | 
				
			||||||
 | 
					        """Send ACPI signal to guest OS to reboot. OS may ignore this."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            self.domain.reboot()
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                f'Cannot reboot instance={self.name}: {e}'
 | 
				
			||||||
 | 
					            ) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def set_autostart(self, *, enabled: bool) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Set autostart flag for instance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param enabled: Bool argument to set or unset autostart flag.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        autostart = 1 if enabled else 0
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            self.domain.setAutostart(autostart)
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                f'Cannot set autostart flag for instance={self.name} '
 | 
				
			||||||
 | 
					                f'{autostart=}: {e}'
 | 
				
			||||||
 | 
					            ) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def set_vcpus(self, nvcpus: int, *, live: bool = False) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Set vCPU number.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        If `live` is True and instance is not currently running vCPUs
 | 
				
			||||||
 | 
					        will set in config and will applied when instance boot.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        NB: Note that if this call is executed before the guest has
 | 
				
			||||||
 | 
					        finished booting, the guest may fail to process the change.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param nvcpus: Number of vCPUs
 | 
				
			||||||
 | 
					        :param live: Affect a running instance
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if nvcpus == 0:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                f'Cannot set zero vCPUs for instance={self.name}'
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        if nvcpus == self.info.nproc:
 | 
				
			||||||
 | 
					            log.warning(
 | 
				
			||||||
 | 
					                'Instance instance=%s already have %s vCPUs, nothing to do',
 | 
				
			||||||
 | 
					                self.name,
 | 
				
			||||||
 | 
					                nvcpus,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            return
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
				
			||||||
 | 
					            self.domain.setVcpusFlags(nvcpus, flags=flags)
 | 
				
			||||||
 | 
					            if live is True:
 | 
				
			||||||
 | 
					                if not self.is_running:
 | 
				
			||||||
 | 
					                    log.warning(
 | 
				
			||||||
 | 
					                        'Instance is not running, changes applied in '
 | 
				
			||||||
 | 
					                        'instance config.'
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					                    return
 | 
				
			||||||
 | 
					                flags = libvirt.VIR_DOMAIN_AFFECT_LIVE
 | 
				
			||||||
 | 
					                self.domain.setVcpusFlags(nvcpus, flags=flags)
 | 
				
			||||||
 | 
					                if self.guest_agent.is_available():
 | 
				
			||||||
 | 
					                    try:
 | 
				
			||||||
 | 
					                        self.guest_agent.raise_for_commands(
 | 
				
			||||||
 | 
					                            ['guest-set-vcpus']
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                        flags = libvirt.VIR_DOMAIN_VCPU_GUEST
 | 
				
			||||||
 | 
					                        self.domain.setVcpusFlags(nvcpus, flags=flags)
 | 
				
			||||||
 | 
					                    except GuestAgentCommandNotSupportedError:
 | 
				
			||||||
 | 
					                        log.warning(
 | 
				
			||||||
 | 
					                            'Cannot set vCPUs in guest via agent, you may '
 | 
				
			||||||
 | 
					                            'need to apply changes in guest manually.'
 | 
				
			||||||
 | 
					                        )
 | 
				
			||||||
 | 
					                else:
 | 
				
			||||||
 | 
					                    log.warning(
 | 
				
			||||||
 | 
					                        'Cannot set vCPUs in guest OS on instance=%s. '
 | 
				
			||||||
 | 
					                        'You may need to apply CPUs in guest manually.',
 | 
				
			||||||
 | 
					                        self.name,
 | 
				
			||||||
 | 
					                    )
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                f'Cannot set vCPUs for instance={self.name}: {e}'
 | 
				
			||||||
 | 
					            ) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def set_memory(self, memory: int, *, live: bool = False) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Set memory.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        If `live` is True and instance is not currently running set memory
 | 
				
			||||||
 | 
					        in config and will applied when instance boot.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param memory: Memory value in mebibytes
 | 
				
			||||||
 | 
					        :param live: Affect a running instance
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if memory == 0:
 | 
				
			||||||
 | 
					            raise InstanceError(
 | 
				
			||||||
 | 
					                f'Cannot set zero memory for instance={self.name}'
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        if live and self.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.setMemoryFlags(
 | 
				
			||||||
 | 
					                memory * 1024, flags=libvirt.VIR_DOMAIN_MEM_MAXIMUM
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            self.domain.setMemoryFlags(memory * 1024, flags=flags)
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            msg = f'Cannot set memory for instance={self.name} {memory=}: {e}'
 | 
				
			||||||
 | 
					            raise InstanceError(msg) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def attach_device(
 | 
				
			||||||
 | 
					        self, device: 'DeviceConfig', *, live: bool = False
 | 
				
			||||||
 | 
					    ) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Attach device to compute instance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param device: Object with device description e.g. DiskConfig
 | 
				
			||||||
 | 
					        :param live: Affect a running instance
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if live and self.is_running:
 | 
				
			||||||
 | 
					            flags = (
 | 
				
			||||||
 | 
					                libvirt.VIR_DOMAIN_AFFECT_LIVE
 | 
				
			||||||
 | 
					                | libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
				
			||||||
 | 
					        self.domain.attachDeviceFlags(device.to_xml(), flags=flags)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def detach_device(
 | 
				
			||||||
 | 
					        self, device: 'DeviceConfig', *, live: bool = False
 | 
				
			||||||
 | 
					    ) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Dettach device from compute instance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param device: Object with device description e.g. DiskConfig
 | 
				
			||||||
 | 
					        :param live: Affect a running instance
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        if live and self.is_running:
 | 
				
			||||||
 | 
					            flags = (
 | 
				
			||||||
 | 
					                libvirt.VIR_DOMAIN_AFFECT_LIVE
 | 
				
			||||||
 | 
					                | libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        else:
 | 
				
			||||||
 | 
					            flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
				
			||||||
 | 
					        self.domain.detachDeviceFlags(device.to_xml(), flags=flags)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def resize_volume(
 | 
				
			||||||
 | 
					        self, name: str, capacity: int, unit: units.DataUnit
 | 
				
			||||||
 | 
					    ) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Resize block device.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param name: Disk device name e.g. `vda`, `sda`, etc.
 | 
				
			||||||
 | 
					        :param capacity: Volume capacity in bytes.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.domain.blockResize(
 | 
				
			||||||
 | 
					            name,
 | 
				
			||||||
 | 
					            units.to_bytes(capacity, unit=unit),
 | 
				
			||||||
 | 
					            flags=libvirt.VIR_DOMAIN_BLOCK_RESIZE_BYTES,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def pause(self) -> None:
 | 
				
			||||||
 | 
					        """Pause instance."""
 | 
				
			||||||
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def resume(self) -> None:
 | 
				
			||||||
 | 
					        """Resume paused instance."""
 | 
				
			||||||
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def list_ssh_keys(self, user: str) -> list[str]:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Get list of SSH keys on guest for specific user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param user: Username.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def set_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Add SSH keys to guest for specific user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param user: Username.
 | 
				
			||||||
 | 
					        :param ssh_keys: List of public SSH keys.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def remove_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Remove SSH keys from guest for specific user.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param user: Username.
 | 
				
			||||||
 | 
					        :param ssh_keys: List of public SSH keys.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        raise NotImplementedError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def set_user_password(self, user: str, password: str) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Set new user password in guest OS.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        This action performs by guest agent inside guest.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param user: Username.
 | 
				
			||||||
 | 
					        :param password: Password.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.domain.setUserPassword(user, password)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def dump_xml(self, *, inactive: bool = False) -> str:
 | 
				
			||||||
 | 
					        """Return instance XML description."""
 | 
				
			||||||
 | 
					        flags = libvirt.VIR_DOMAIN_XML_INACTIVE if inactive else 0
 | 
				
			||||||
 | 
					        return self.domain.XMLDesc(flags)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete(self) -> None:
 | 
				
			||||||
 | 
					        """Undefine instance and delete local volumes."""
 | 
				
			||||||
 | 
					        self.shutdown(method='HARD')
 | 
				
			||||||
 | 
					        self.domain.undefine()
 | 
				
			||||||
							
								
								
									
										126
									
								
								compute/instance/schemas.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								compute/instance/schemas.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,126 @@
 | 
				
			|||||||
 | 
					"""Compute instance related objects schemas."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import re
 | 
				
			||||||
 | 
					from enum import StrEnum
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from pydantic import BaseModel, validator
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from compute.utils.units import DataUnit
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CPUEmulationMode(StrEnum):
 | 
				
			||||||
 | 
					    """CPU emulation mode enumerated."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    HOST_PASSTHROUGH = 'host-passthrough'
 | 
				
			||||||
 | 
					    HOST_MODEL = 'host-model'
 | 
				
			||||||
 | 
					    CUSTOM = 'custom'
 | 
				
			||||||
 | 
					    MAXIMUM = 'maximum'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CPUTopologySchema(BaseModel):
 | 
				
			||||||
 | 
					    """CPU topology model."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    sockets: int
 | 
				
			||||||
 | 
					    cores: int
 | 
				
			||||||
 | 
					    threads: int
 | 
				
			||||||
 | 
					    dies: int = 1
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CPUFeaturesSchema(BaseModel):
 | 
				
			||||||
 | 
					    """CPU features model."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    require: list[str]
 | 
				
			||||||
 | 
					    disable: list[str]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class CPUSchema(BaseModel):
 | 
				
			||||||
 | 
					    """CPU model."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    emulation_mode: CPUEmulationMode
 | 
				
			||||||
 | 
					    model: str
 | 
				
			||||||
 | 
					    vendor: str
 | 
				
			||||||
 | 
					    topology: CPUTopologySchema
 | 
				
			||||||
 | 
					    features: CPUFeaturesSchema
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StorageVolumeType(StrEnum):
 | 
				
			||||||
 | 
					    """Storage volume types enumeration."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    FILE = 'file'
 | 
				
			||||||
 | 
					    NETWORK = 'network'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StorageVolumeCapacitySchema(BaseModel):
 | 
				
			||||||
 | 
					    """Storage volume capacity field model."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    value: int
 | 
				
			||||||
 | 
					    unit: DataUnit
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StorageVolumeSchema(BaseModel):
 | 
				
			||||||
 | 
					    """Storage volume model."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    type: StorageVolumeType  # noqa: A003
 | 
				
			||||||
 | 
					    source: Path
 | 
				
			||||||
 | 
					    target: str
 | 
				
			||||||
 | 
					    capacity: StorageVolumeCapacitySchema
 | 
				
			||||||
 | 
					    readonly: bool = False
 | 
				
			||||||
 | 
					    is_system: bool = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class NetworkInterfaceSchema(BaseModel):
 | 
				
			||||||
 | 
					    """Network inerface model."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    source: str
 | 
				
			||||||
 | 
					    mac: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class BootOptionsSchema(BaseModel):
 | 
				
			||||||
 | 
					    """Instance boot settings."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    order: tuple
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InstanceSchema(BaseModel):
 | 
				
			||||||
 | 
					    """Compute instance model."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    name: str
 | 
				
			||||||
 | 
					    title: str
 | 
				
			||||||
 | 
					    description: str
 | 
				
			||||||
 | 
					    memory: int
 | 
				
			||||||
 | 
					    max_memory: int
 | 
				
			||||||
 | 
					    vcpus: int
 | 
				
			||||||
 | 
					    max_vcpus: int
 | 
				
			||||||
 | 
					    cpu: CPUSchema
 | 
				
			||||||
 | 
					    machine: str
 | 
				
			||||||
 | 
					    emulator: Path
 | 
				
			||||||
 | 
					    arch: str
 | 
				
			||||||
 | 
					    image: str
 | 
				
			||||||
 | 
					    boot: BootOptionsSchema
 | 
				
			||||||
 | 
					    volumes: list[StorageVolumeSchema]
 | 
				
			||||||
 | 
					    network_interfaces: list[NetworkInterfaceSchema]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @validator('name')
 | 
				
			||||||
 | 
					    def _check_name(cls, value: str) -> str:  # noqa: N805
 | 
				
			||||||
 | 
					        if not re.match(r'^[a-z0-9_]+$', value):
 | 
				
			||||||
 | 
					            msg = (
 | 
				
			||||||
 | 
					                'Name can contain only lowercase letters, numbers '
 | 
				
			||||||
 | 
					                'and underscore.'
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            raise ValueError(msg)
 | 
				
			||||||
 | 
					        return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @validator('volumes')
 | 
				
			||||||
 | 
					    def _check_volumes(cls, value: list) -> list:  # noqa: N805
 | 
				
			||||||
 | 
					        if len([v for v in value if v.is_system is True]) != 1:
 | 
				
			||||||
 | 
					            msg = 'Volumes list must contain one system volume'
 | 
				
			||||||
 | 
					            raise ValueError(msg)
 | 
				
			||||||
 | 
					        return value
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @validator('network_interfaces')
 | 
				
			||||||
 | 
					    def _check_network_interfaces(cls, value: list) -> list:  # noqa: N805
 | 
				
			||||||
 | 
					        if not value:
 | 
				
			||||||
 | 
					            msg = 'Network interfaces list must contain at least one element'
 | 
				
			||||||
 | 
					            raise ValueError(msg)
 | 
				
			||||||
 | 
					        return value
 | 
				
			||||||
							
								
								
									
										156
									
								
								compute/session.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										156
									
								
								compute/session.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,156 @@
 | 
				
			|||||||
 | 
					"""Hypervisor session manager."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					import os
 | 
				
			||||||
 | 
					from contextlib import AbstractContextManager
 | 
				
			||||||
 | 
					from types import TracebackType
 | 
				
			||||||
 | 
					from typing import Any, NamedTuple
 | 
				
			||||||
 | 
					from uuid import uuid4
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					from lxml import etree
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .exceptions import InstanceNotFoundError, SessionError
 | 
				
			||||||
 | 
					from .instance import Instance, InstanceConfig, InstanceSchema
 | 
				
			||||||
 | 
					from .storage import DiskConfig, StoragePool, VolumeConfig
 | 
				
			||||||
 | 
					from .utils import units
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					log = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Capabilities(NamedTuple):
 | 
				
			||||||
 | 
					    """Store domain capabilities info."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    arch: str
 | 
				
			||||||
 | 
					    virt: str
 | 
				
			||||||
 | 
					    emulator: str
 | 
				
			||||||
 | 
					    machine: str
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class Session(AbstractContextManager):
 | 
				
			||||||
 | 
					    """Hypervisor session manager."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, uri: str | None = None):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Initialise session with hypervisor.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param uri: libvirt connection URI.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.IMAGES_POOL = os.getenv('CMP_IMAGES_POOL')
 | 
				
			||||||
 | 
					        self.VOLUMES_POOL = os.getenv('CMP_VOLUMES_POOL')
 | 
				
			||||||
 | 
					        self.uri = uri or 'qemu:///system'
 | 
				
			||||||
 | 
					        self.connection = libvirt.open(self.uri)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __enter__(self):
 | 
				
			||||||
 | 
					        """Return Session object."""
 | 
				
			||||||
 | 
					        return self
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __exit__(
 | 
				
			||||||
 | 
					        self,
 | 
				
			||||||
 | 
					        exc_type: type[BaseException] | None,
 | 
				
			||||||
 | 
					        exc_value: BaseException | None,
 | 
				
			||||||
 | 
					        exc_traceback: TracebackType | None,
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        """Close the connection when leaving the context."""
 | 
				
			||||||
 | 
					        self.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def close(self) -> None:
 | 
				
			||||||
 | 
					        """Close connection to libvirt daemon."""
 | 
				
			||||||
 | 
					        self.connection.close()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def capabilities(self) -> Capabilities:
 | 
				
			||||||
 | 
					        """Return capabilities e.g. arch, virt, emulator, etc."""
 | 
				
			||||||
 | 
					        prefix = '/domainCapabilities'
 | 
				
			||||||
 | 
					        caps = etree.fromstring(self.connection.getDomainCapabilities())  # noqa: S320
 | 
				
			||||||
 | 
					        return Capabilities(
 | 
				
			||||||
 | 
					            arch=caps.xpath(f'{prefix}/arch/text()')[0],
 | 
				
			||||||
 | 
					            virt=caps.xpath(f'{prefix}/domain/text()')[0],
 | 
				
			||||||
 | 
					            emulator=caps.xpath(f'{prefix}/path/text()')[0],
 | 
				
			||||||
 | 
					            machine=caps.xpath(f'{prefix}/machine/text()')[0],
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def create_instance(self, **kwargs: Any) -> Instance:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Create and return new compute instance.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param name str: Instance name.
 | 
				
			||||||
 | 
					        :param title str: Instance title for humans.
 | 
				
			||||||
 | 
					        :param description str: Some information about instance
 | 
				
			||||||
 | 
					        :param memory int: Memory in MiB.
 | 
				
			||||||
 | 
					        :param max_memory int: Maximum memory in MiB.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # TODO @ge: create instances in transaction
 | 
				
			||||||
 | 
					        data = InstanceSchema(**kwargs)
 | 
				
			||||||
 | 
					        config = InstanceConfig(data)
 | 
				
			||||||
 | 
					        log.info('Define XML...')
 | 
				
			||||||
 | 
					        log.info(config.to_xml())
 | 
				
			||||||
 | 
					        self.connection.defineXML(config.to_xml())
 | 
				
			||||||
 | 
					        log.info('Getting instance...')
 | 
				
			||||||
 | 
					        instance = self.get_instance(config.name)
 | 
				
			||||||
 | 
					        log.info('Creating volumes...')
 | 
				
			||||||
 | 
					        for volume in data.volumes:
 | 
				
			||||||
 | 
					            log.info('Creating volume=%s', volume)
 | 
				
			||||||
 | 
					            capacity = units.to_bytes(
 | 
				
			||||||
 | 
					                volume.capacity.value, volume.capacity.unit
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            log.info('Connecting to images pool...')
 | 
				
			||||||
 | 
					            images_pool = self.get_storage_pool(self.IMAGES_POOL)
 | 
				
			||||||
 | 
					            log.info('Connecting to volumes pool...')
 | 
				
			||||||
 | 
					            volumes_pool = self.get_storage_pool(self.VOLUMES_POOL)
 | 
				
			||||||
 | 
					            log.info('Building volume configuration...')
 | 
				
			||||||
 | 
					            # if not volume.source:
 | 
				
			||||||
 | 
					            # В случае если пользователь передаёт source для волюма, следует
 | 
				
			||||||
 | 
					            # в либвирте делать поиск волюма по пути, а не по имени
 | 
				
			||||||
 | 
					            #    gen_vol_name
 | 
				
			||||||
 | 
					            # TODO @ge: come up with something else
 | 
				
			||||||
 | 
					            vol_name = f'{config.name}-{volume.target}-{uuid4()}.qcow2'
 | 
				
			||||||
 | 
					            vol_conf = VolumeConfig(
 | 
				
			||||||
 | 
					                name=vol_name,
 | 
				
			||||||
 | 
					                path=str(volumes_pool.path.joinpath(vol_name)),
 | 
				
			||||||
 | 
					                capacity=capacity,
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					            log.info('Volume configuration is:\n %s', vol_conf.to_xml())
 | 
				
			||||||
 | 
					            if volume.is_system is True:
 | 
				
			||||||
 | 
					                log.info(
 | 
				
			||||||
 | 
					                    "Volume is marked as 'system', start cloning image..."
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                log.info('Get image %s', data.image)
 | 
				
			||||||
 | 
					                image = images_pool.get_volume(data.image)
 | 
				
			||||||
 | 
					                log.info('Cloning image into volumes pool...')
 | 
				
			||||||
 | 
					                vol = volumes_pool.clone_volume(image, vol_conf)
 | 
				
			||||||
 | 
					                log.info(
 | 
				
			||||||
 | 
					                    'Resize cloned volume to specified size: %s',
 | 
				
			||||||
 | 
					                    capacity,
 | 
				
			||||||
 | 
					                )
 | 
				
			||||||
 | 
					                vol.resize(capacity, unit=units.DataUnit.BYTES)
 | 
				
			||||||
 | 
					            else:
 | 
				
			||||||
 | 
					                log.info('Create volume...')
 | 
				
			||||||
 | 
					                volumes_pool.create_volume(vol_conf)
 | 
				
			||||||
 | 
					            log.info('Attaching volume to instance...')
 | 
				
			||||||
 | 
					            instance.attach_device(
 | 
				
			||||||
 | 
					                DiskConfig(path=vol_conf.path, target=volume.target)
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        return instance
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_instance(self, name: str) -> Instance:
 | 
				
			||||||
 | 
					        """Get compute instance by name."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            return Instance(self.connection.lookupByName(name))
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as err:
 | 
				
			||||||
 | 
					            if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
 | 
				
			||||||
 | 
					                raise InstanceNotFoundError(name) from err
 | 
				
			||||||
 | 
					            raise SessionError(err) from err
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def list_instances(self) -> list[Instance]:
 | 
				
			||||||
 | 
					        """List all instances."""
 | 
				
			||||||
 | 
					        return [Instance(dom) for dom in self.connection.listAllDomains()]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_storage_pool(self, name: str) -> StoragePool:
 | 
				
			||||||
 | 
					        """Get storage pool by name."""
 | 
				
			||||||
 | 
					        # TODO @ge: handle Storage pool not found error
 | 
				
			||||||
 | 
					        return StoragePool(self.connection.storagePoolLookupByName(name))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def list_storage_pools(self) -> list[StoragePool]:
 | 
				
			||||||
 | 
					        """List all strage pools."""
 | 
				
			||||||
 | 
					        return [StoragePool(p) for p in self.connection.listStoragePools()]
 | 
				
			||||||
							
								
								
									
										2
									
								
								compute/storage/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								compute/storage/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
				
			|||||||
 | 
					from .pool import StoragePool
 | 
				
			||||||
 | 
					from .volume import DiskConfig, Volume, VolumeConfig
 | 
				
			||||||
							
								
								
									
										114
									
								
								compute/storage/pool.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										114
									
								
								compute/storage/pool.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,114 @@
 | 
				
			|||||||
 | 
					"""Manage storage pools."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import logging
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from typing import NamedTuple
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					from lxml import etree
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from compute.exceptions import StoragePoolError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from .volume import Volume, VolumeConfig
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					log = logging.getLogger(__name__)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StoragePoolUsage(NamedTuple):
 | 
				
			||||||
 | 
					    """Storage pool usage info schema."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    capacity: int
 | 
				
			||||||
 | 
					    allocation: int
 | 
				
			||||||
 | 
					    available: int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class StoragePool:
 | 
				
			||||||
 | 
					    """Storage pool manipulating class."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, pool: libvirt.virStoragePool):
 | 
				
			||||||
 | 
					        """Initislise StoragePool."""
 | 
				
			||||||
 | 
					        self.pool = pool
 | 
				
			||||||
 | 
					        self.name = pool.name()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def path(self) -> Path:
 | 
				
			||||||
 | 
					        """Return storage pool path."""
 | 
				
			||||||
 | 
					        xml = etree.fromstring(self.pool.XMLDesc())  # noqa: S320
 | 
				
			||||||
 | 
					        return Path(xml.xpath('/pool/target/path/text()')[0])
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def usage(self) -> StoragePoolUsage:
 | 
				
			||||||
 | 
					        """Return info about storage pool usage."""
 | 
				
			||||||
 | 
					        xml = etree.fromstring(self.pool.XMLDesc())  # noqa: S320
 | 
				
			||||||
 | 
					        return StoragePoolUsage(
 | 
				
			||||||
 | 
					            capacity=int(xml.xpath('/pool/capacity/text()')[0]),
 | 
				
			||||||
 | 
					            allocation=int(xml.xpath('/pool/allocation/text()')[0]),
 | 
				
			||||||
 | 
					            available=int(xml.xpath('/pool/available/text()')[0]),
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def dump_xml(self) -> str:
 | 
				
			||||||
 | 
					        """Return storage pool XML description as string."""
 | 
				
			||||||
 | 
					        return self.pool.XMLDesc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def refresh(self) -> None:
 | 
				
			||||||
 | 
					        """Refresh storage pool."""
 | 
				
			||||||
 | 
					        # TODO @ge: handle libvirt asynchronous job related exceptions
 | 
				
			||||||
 | 
					        self.pool.refresh()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def create_volume(self, vol_conf: VolumeConfig) -> Volume:
 | 
				
			||||||
 | 
					        """Create storage volume and return Volume instance."""
 | 
				
			||||||
 | 
					        log.info(
 | 
				
			||||||
 | 
					            'Create storage volume vol=%s in pool=%s', vol_conf.name, self.pool
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        vol = self.pool.createXML(
 | 
				
			||||||
 | 
					            vol_conf.to_xml(),
 | 
				
			||||||
 | 
					            flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return Volume(self.pool, vol)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def clone_volume(self, src: Volume, dst: VolumeConfig) -> Volume:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Make storage volume copy.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param src: Input volume
 | 
				
			||||||
 | 
					        :param dst: Output volume config
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        log.info(
 | 
				
			||||||
 | 
					            'Start volume cloning '
 | 
				
			||||||
 | 
					            'src_pool=%s src_vol=%s dst_pool=%s dst_vol=%s',
 | 
				
			||||||
 | 
					            src.pool_name,
 | 
				
			||||||
 | 
					            src.name,
 | 
				
			||||||
 | 
					            self.pool.name,
 | 
				
			||||||
 | 
					            dst.name,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        vol = self.pool.createXMLFrom(
 | 
				
			||||||
 | 
					            dst.to_xml(),  # new volume XML description
 | 
				
			||||||
 | 
					            src.vol,  # source volume virStorageVol object
 | 
				
			||||||
 | 
					            flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        if vol is None:
 | 
				
			||||||
 | 
					            raise StoragePoolError
 | 
				
			||||||
 | 
					        return Volume(self.pool, vol)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def get_volume(self, name: str) -> Volume | None:
 | 
				
			||||||
 | 
					        """Lookup and return Volume instance or None."""
 | 
				
			||||||
 | 
					        log.info(
 | 
				
			||||||
 | 
					            'Lookup for storage volume vol=%s in pool=%s', name, self.pool.name
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            vol = self.pool.storageVolLookupByName(name)
 | 
				
			||||||
 | 
					            return Volume(self.pool, vol)
 | 
				
			||||||
 | 
					        except libvirt.libvirtError as e:
 | 
				
			||||||
 | 
					            # TODO @ge: Raise VolumeNotFoundError instead
 | 
				
			||||||
 | 
					            if (
 | 
				
			||||||
 | 
					                e.get_error_domain() == libvirt.VIR_FROM_STORAGE
 | 
				
			||||||
 | 
					                or e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL
 | 
				
			||||||
 | 
					            ):
 | 
				
			||||||
 | 
					                log.exception(e.get_error_message())
 | 
				
			||||||
 | 
					                return None
 | 
				
			||||||
 | 
					            log.exception('unexpected error from libvirt')
 | 
				
			||||||
 | 
					            raise StoragePoolError(e) from e
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def list_volumes(self) -> list[Volume]:
 | 
				
			||||||
 | 
					        """Return list of volumes in storage pool."""
 | 
				
			||||||
 | 
					        return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()]
 | 
				
			||||||
							
								
								
									
										124
									
								
								compute/storage/volume.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										124
									
								
								compute/storage/volume.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,124 @@
 | 
				
			|||||||
 | 
					"""Manage storage volumes."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from dataclasses import dataclass
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					from time import time
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import libvirt
 | 
				
			||||||
 | 
					from lxml import etree
 | 
				
			||||||
 | 
					from lxml.builder import E
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from compute.utils import units
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class VolumeConfig:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Storage volume config builder.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Generate XML config for creating a volume in a libvirt
 | 
				
			||||||
 | 
					    storage pool.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    name: str
 | 
				
			||||||
 | 
					    path: str
 | 
				
			||||||
 | 
					    capacity: int
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_xml(self) -> str:
 | 
				
			||||||
 | 
					        """Return XML config for libvirt."""
 | 
				
			||||||
 | 
					        unixtime = str(int(time()))
 | 
				
			||||||
 | 
					        xml = E.volume(type='file')
 | 
				
			||||||
 | 
					        xml.append(E.name(self.name))
 | 
				
			||||||
 | 
					        xml.append(E.key(self.path))
 | 
				
			||||||
 | 
					        xml.append(E.source())
 | 
				
			||||||
 | 
					        xml.append(E.capacity(str(self.capacity), unit='bytes'))
 | 
				
			||||||
 | 
					        xml.append(E.allocation('0'))
 | 
				
			||||||
 | 
					        xml.append(
 | 
				
			||||||
 | 
					            E.target(
 | 
				
			||||||
 | 
					                E.path(self.path),
 | 
				
			||||||
 | 
					                E.format(type='qcow2'),
 | 
				
			||||||
 | 
					                E.timestamps(
 | 
				
			||||||
 | 
					                    E.atime(unixtime), E.mtime(unixtime), E.ctime(unixtime)
 | 
				
			||||||
 | 
					                ),
 | 
				
			||||||
 | 
					                E.compat('1.1'),
 | 
				
			||||||
 | 
					                E.features(E.lazy_refcounts()),
 | 
				
			||||||
 | 
					            )
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        return etree.tostring(xml, encoding='unicode', pretty_print=True)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@dataclass
 | 
				
			||||||
 | 
					class DiskConfig:
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					    Disk config builder.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    Generate XML config for attaching or detaching storage volumes
 | 
				
			||||||
 | 
					    to compute instances.
 | 
				
			||||||
 | 
					    """
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    target: str
 | 
				
			||||||
 | 
					    path: str
 | 
				
			||||||
 | 
					    readonly: bool = False
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def to_xml(self) -> str:
 | 
				
			||||||
 | 
					        """Return XML config for libvirt."""
 | 
				
			||||||
 | 
					        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:
 | 
				
			||||||
 | 
					    """Storage volume manipulating class."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(
 | 
				
			||||||
 | 
					        self, pool: libvirt.virStoragePool, vol: libvirt.virStorageVol
 | 
				
			||||||
 | 
					    ):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Initialise Volume.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param pool: libvirt virStoragePool object
 | 
				
			||||||
 | 
					        :param vol: libvirt virStorageVol object
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.pool = pool
 | 
				
			||||||
 | 
					        self.pool_name = pool.name()
 | 
				
			||||||
 | 
					        self.vol = vol
 | 
				
			||||||
 | 
					        self.name = vol.name()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    @property
 | 
				
			||||||
 | 
					    def path(self) -> Path:
 | 
				
			||||||
 | 
					        """Return path to volume."""
 | 
				
			||||||
 | 
					        return Path(self.vol.path())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def dump_xml(self) -> str:
 | 
				
			||||||
 | 
					        """Return volume XML description as string."""
 | 
				
			||||||
 | 
					        return self.vol.XMLDesc()
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def clone(self, vol_conf: VolumeConfig) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Make a copy of volume to the same storage pool.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param vol_info VolumeInfo: New storage volume dataclass object
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        self.pool.createXMLFrom(
 | 
				
			||||||
 | 
					            vol_conf.to_xml(),
 | 
				
			||||||
 | 
					            self.vol,
 | 
				
			||||||
 | 
					            flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def resize(self, capacity: int, unit: units.DataUnit) -> None:
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Resize volume.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param capacity int: Volume new capacity.
 | 
				
			||||||
 | 
					        :param unit DataUnit: Data unit. Internally converts into bytes.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # TODO @ge: Check actual volume size before resize
 | 
				
			||||||
 | 
					        self.vol.resize(units.to_bytes(capacity, unit=unit))
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def delete(self) -> None:
 | 
				
			||||||
 | 
					        """Delete volume from storage pool."""
 | 
				
			||||||
 | 
					        self.vol.delete()
 | 
				
			||||||
							
								
								
									
										0
									
								
								compute/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								compute/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										41
									
								
								compute/utils/config_loader.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										41
									
								
								compute/utils/config_loader.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,41 @@
 | 
				
			|||||||
 | 
					"""Configuration loader."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import tomllib
 | 
				
			||||||
 | 
					from collections import UserDict
 | 
				
			||||||
 | 
					from pathlib import Path
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from compute.exceptions import ConfigLoaderError
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					DEFAULT_CONFIGURATION = {}
 | 
				
			||||||
 | 
					DEFAULT_CONFIG_FILE = '/etc/computed/computed.toml'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class ConfigLoader(UserDict):
 | 
				
			||||||
 | 
					    """UserDict for storing configuration."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, file: Path | None = None):
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        Initialise ConfigLoader.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        :param file: Path to configuration file. If `file` is None
 | 
				
			||||||
 | 
					            use default path from DEFAULT_CONFIG_FILE constant.
 | 
				
			||||||
 | 
					        """
 | 
				
			||||||
 | 
					        # TODO @ge: load deafult configuration
 | 
				
			||||||
 | 
					        self.file = Path(file) if file else Path(DEFAULT_CONFIG_FILE)
 | 
				
			||||||
 | 
					        super().__init__(self.load())
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def load(self) -> dict:
 | 
				
			||||||
 | 
					        """Load confguration object from TOML file."""
 | 
				
			||||||
 | 
					        try:
 | 
				
			||||||
 | 
					            with Path(self.file).open('rb') as configfile:
 | 
				
			||||||
 | 
					                return tomllib.load(configfile)
 | 
				
			||||||
 | 
					                # TODO @ge: add config schema validation
 | 
				
			||||||
 | 
					        except tomllib.TOMLDecodeError as tomlerr:
 | 
				
			||||||
 | 
					            raise ConfigLoaderError(
 | 
				
			||||||
 | 
					                f'Bad TOML syntax in config file: {self.file}: {tomlerr}'
 | 
				
			||||||
 | 
					            ) from tomlerr
 | 
				
			||||||
 | 
					        except (OSError, ValueError) as readerr:
 | 
				
			||||||
 | 
					            raise ConfigLoaderError(
 | 
				
			||||||
 | 
					                f'Cannot read config file: {self.file}: {readerr}'
 | 
				
			||||||
 | 
					            ) from readerr
 | 
				
			||||||
							
								
								
									
										18
									
								
								compute/utils/identifiers.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										18
									
								
								compute/utils/identifiers.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,18 @@
 | 
				
			|||||||
 | 
					"""Random identificators."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# ruff: noqa: S311, C417
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					import random
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def random_mac() -> str:
 | 
				
			||||||
 | 
					    """Retrun random MAC address."""
 | 
				
			||||||
 | 
					    mac = [
 | 
				
			||||||
 | 
					        0x00,
 | 
				
			||||||
 | 
					        0x16,
 | 
				
			||||||
 | 
					        0x3E,
 | 
				
			||||||
 | 
					        random.randint(0x00, 0x7F),
 | 
				
			||||||
 | 
					        random.randint(0x00, 0xFF),
 | 
				
			||||||
 | 
					        random.randint(0x00, 0xFF),
 | 
				
			||||||
 | 
					    ]
 | 
				
			||||||
 | 
					    return ':'.join(map(lambda x: '%02x' % x, mac))
 | 
				
			||||||
							
								
								
									
										39
									
								
								compute/utils/units.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										39
									
								
								compute/utils/units.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,39 @@
 | 
				
			|||||||
 | 
					"""Tools for data units convertion."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					from enum import StrEnum
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class DataUnit(StrEnum):
 | 
				
			||||||
 | 
					    """Data units enumerated."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    BYTES = 'bytes'
 | 
				
			||||||
 | 
					    KIB = 'KiB'
 | 
				
			||||||
 | 
					    MIB = 'MiB'
 | 
				
			||||||
 | 
					    GIB = 'GiB'
 | 
				
			||||||
 | 
					    TIB = 'TiB'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					class InvalidDataUnitError(ValueError):
 | 
				
			||||||
 | 
					    """Data unit is not valid."""
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    def __init__(self, msg: str):
 | 
				
			||||||
 | 
					        """Initialise InvalidDataUnitError."""
 | 
				
			||||||
 | 
					        super().__init__(
 | 
				
			||||||
 | 
					            f'{msg}, valid units are: {", ".join(list(DataUnit))}'
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					def to_bytes(value: int, unit: DataUnit = DataUnit.BYTES) -> int:
 | 
				
			||||||
 | 
					    """Convert value to bytes. See `DataUnit`."""
 | 
				
			||||||
 | 
					    try:
 | 
				
			||||||
 | 
					        _ = DataUnit(unit)
 | 
				
			||||||
 | 
					    except ValueError as e:
 | 
				
			||||||
 | 
					        raise InvalidDataUnitError(e) from e
 | 
				
			||||||
 | 
					    powers = {
 | 
				
			||||||
 | 
					        DataUnit.BYTES: 0,
 | 
				
			||||||
 | 
					        DataUnit.KIB: 1,
 | 
				
			||||||
 | 
					        DataUnit.MIB: 2,
 | 
				
			||||||
 | 
					        DataUnit.GIB: 3,
 | 
				
			||||||
 | 
					        DataUnit.TIB: 4,
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    return value * pow(1024, powers[unit])
 | 
				
			||||||
@@ -1,5 +0,0 @@
 | 
				
			|||||||
from .config import ConfigLoader
 | 
					 | 
				
			||||||
from .exceptions import *
 | 
					 | 
				
			||||||
from .session import LibvirtSession
 | 
					 | 
				
			||||||
from .vm import *
 | 
					 | 
				
			||||||
from .volume import *
 | 
					 | 
				
			||||||
@@ -1,108 +0,0 @@
 | 
				
			|||||||
"""
 | 
					 | 
				
			||||||
Manage virtual machines.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Usage:  na-vmctl [options] status <machine>
 | 
					 | 
				
			||||||
        na-vmctl [options] is-running <machine>
 | 
					 | 
				
			||||||
        na-vmctl [options] start <machine>
 | 
					 | 
				
			||||||
        na-vmctl [options] shutdown <machine>
 | 
					 | 
				
			||||||
        na-vmctl [options] set-vcpus <machine> <nvcpus>
 | 
					 | 
				
			||||||
        na-vmctl [options] set-memory <machine> <memory>
 | 
					 | 
				
			||||||
        na-vmctl [options] list [-a|--all]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Options:
 | 
					 | 
				
			||||||
    -c, --config <file>  config file [default: /etc/node-agent/config.yaml]
 | 
					 | 
				
			||||||
    -l, --loglvl <lvl>   logging level
 | 
					 | 
				
			||||||
    -a, --all            list all machines including inactive
 | 
					 | 
				
			||||||
"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import logging
 | 
					 | 
				
			||||||
import pathlib
 | 
					 | 
				
			||||||
import sys
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import libvirt
 | 
					 | 
				
			||||||
from docopt import docopt
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from ..exceptions import VMError, VMNotFound
 | 
					 | 
				
			||||||
from ..session import LibvirtSession
 | 
					 | 
				
			||||||
from ..vm import VirtualMachine
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
logger = logging.getLogger(__name__)
 | 
					 | 
				
			||||||
levels = logging.getLevelNamesMapping()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
libvirt.registerErrorHandler(lambda userdata, err: None, ctx=None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Color:
 | 
					 | 
				
			||||||
    RED = '\033[31m'
 | 
					 | 
				
			||||||
    GREEN = '\033[32m'
 | 
					 | 
				
			||||||
    YELLOW = '\033[33m'
 | 
					 | 
				
			||||||
    NONE = '\033[0m'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Table:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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():
 | 
					 | 
				
			||||||
    args = docopt(__doc__)
 | 
					 | 
				
			||||||
    config = pathlib.Path(args['--config']) or None
 | 
					 | 
				
			||||||
    loglvl = None
 | 
					 | 
				
			||||||
    machine = args['<machine>']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if args['--loglvl']:
 | 
					 | 
				
			||||||
        loglvl = args['--loglvl'].upper()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if loglvl in levels:
 | 
					 | 
				
			||||||
        logging.basicConfig(level=levels[loglvl])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    with LibvirtSession() as session:
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            if args['list']:
 | 
					 | 
				
			||||||
                table = Table()
 | 
					 | 
				
			||||||
                table.header(['NAME', 'STATE', 'AUTOSTART'])
 | 
					 | 
				
			||||||
                for vm_ in session.list_machines():
 | 
					 | 
				
			||||||
                    table.row([vm_.name, vm_.status, vm_.is_autostart])
 | 
					 | 
				
			||||||
                table.print()
 | 
					 | 
				
			||||||
                sys.exit()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
            vm = session.get_machine(machine)
 | 
					 | 
				
			||||||
            if args['status']:
 | 
					 | 
				
			||||||
                print(vm.status)
 | 
					 | 
				
			||||||
            if args['is-running']:
 | 
					 | 
				
			||||||
                if vm.is_running:
 | 
					 | 
				
			||||||
                    print('running')
 | 
					 | 
				
			||||||
                else:
 | 
					 | 
				
			||||||
                    sys.exit(vm.status)
 | 
					 | 
				
			||||||
            if args['start']:
 | 
					 | 
				
			||||||
                vm.start()
 | 
					 | 
				
			||||||
                print(f'{vm.name} started')
 | 
					 | 
				
			||||||
            if args['shutdown']:
 | 
					 | 
				
			||||||
                vm.shutdown('NORMAL')
 | 
					 | 
				
			||||||
        except VMNotFound as nferr:
 | 
					 | 
				
			||||||
            sys.exit(f'{Color.RED}VM {machine} not found.{Color.NONE}')
 | 
					 | 
				
			||||||
        except VMError as vmerr:
 | 
					 | 
				
			||||||
            sys.exit(f'{Color.RED}{vmerr}{Color.NONE}')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if __name__ == '__main__':
 | 
					 | 
				
			||||||
    cli()
 | 
					 | 
				
			||||||
@@ -1,84 +0,0 @@
 | 
				
			|||||||
"""
 | 
					 | 
				
			||||||
Execute shell commands on guest via guest agent.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Usage:  na-vmexec [options] <machine> <command>
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
Options:
 | 
					 | 
				
			||||||
    -c, --config <file>  config file [default: /etc/node-agent/config.yaml]
 | 
					 | 
				
			||||||
    -l, --loglvl <lvl>   logging level
 | 
					 | 
				
			||||||
    -s, --shell <shell>  guest shell [default: /bin/sh]
 | 
					 | 
				
			||||||
    -t, --timeout <sec>  QEMU timeout in seconds to stop polling command status [default: 60]
 | 
					 | 
				
			||||||
    -p, --pid <PID>      PID on guest to poll output
 | 
					 | 
				
			||||||
"""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import logging
 | 
					 | 
				
			||||||
import pathlib
 | 
					 | 
				
			||||||
import sys
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import libvirt
 | 
					 | 
				
			||||||
from docopt import docopt
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from ..exceptions import GuestAgentError, VMNotFound
 | 
					 | 
				
			||||||
from ..session import LibvirtSession
 | 
					 | 
				
			||||||
from ..vm import GuestAgent
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
logger = logging.getLogger(__name__)
 | 
					 | 
				
			||||||
levels = logging.getLevelNamesMapping()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
libvirt.registerErrorHandler(lambda userdata, err: None, ctx=None)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class Color:
 | 
					 | 
				
			||||||
    RED = '\033[31m'
 | 
					 | 
				
			||||||
    GREEN = '\033[32m'
 | 
					 | 
				
			||||||
    YELLOW = '\033[33m'
 | 
					 | 
				
			||||||
    NONE = '\033[0m'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
# TODO: Add STDIN support e.g.: cat something.sh | na-vmexec vmname bash
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def cli():
 | 
					 | 
				
			||||||
    args = docopt(__doc__)
 | 
					 | 
				
			||||||
    config = pathlib.Path(args['--config']) or None
 | 
					 | 
				
			||||||
    loglvl = None
 | 
					 | 
				
			||||||
    machine = args['<machine>']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if args['--loglvl']:
 | 
					 | 
				
			||||||
        loglvl = args['--loglvl'].upper()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if loglvl in levels:
 | 
					 | 
				
			||||||
        logging.basicConfig(level=levels[loglvl])
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    with LibvirtSession() as session:
 | 
					 | 
				
			||||||
        shell = args['--shell']
 | 
					 | 
				
			||||||
        cmd = args['<command>']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            ga = session.get_guest_agent(machine)
 | 
					 | 
				
			||||||
            exited, exitcode, stdout, stderr = ga.shellexec(
 | 
					 | 
				
			||||||
                cmd, executable=shell, capture_output=True, decode_output=True,
 | 
					 | 
				
			||||||
                timeout=int(args['--timeout']))
 | 
					 | 
				
			||||||
        except GuestAgentError as gaerr:
 | 
					 | 
				
			||||||
            errmsg = f'{Color.RED}{gaerr}{Color.NONE}'
 | 
					 | 
				
			||||||
            if str(gaerr).startswith('Polling command pid='):
 | 
					 | 
				
			||||||
                errmsg = (errmsg + Color.YELLOW +
 | 
					 | 
				
			||||||
                          '\n[NOTE: command may still running on guest '
 | 
					 | 
				
			||||||
                          'pid={ga.last_pid}]' + Color.NONE)
 | 
					 | 
				
			||||||
            sys.exit(errmsg)
 | 
					 | 
				
			||||||
        except VMNotFound as err:
 | 
					 | 
				
			||||||
            sys.exit(f'{Color.RED}VM {machine} not found{Color.NONE}')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if not exited:
 | 
					 | 
				
			||||||
        print(Color.YELLOW +
 | 
					 | 
				
			||||||
              '[NOTE: command may still running on guest pid={ga.last_pid}]' +
 | 
					 | 
				
			||||||
              Color.NONE, file=sys.stderr)
 | 
					 | 
				
			||||||
    if stderr:
 | 
					 | 
				
			||||||
        print(stderr.strip(), file=sys.stderr)
 | 
					 | 
				
			||||||
    if stdout:
 | 
					 | 
				
			||||||
        print(stdout.strip(), file=sys.stdout)
 | 
					 | 
				
			||||||
    sys.exit(exitcode)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
if __name__ == '__main__':
 | 
					 | 
				
			||||||
    cli()
 | 
					 | 
				
			||||||
@@ -1,36 +0,0 @@
 | 
				
			|||||||
import os
 | 
					 | 
				
			||||||
import tomllib
 | 
					 | 
				
			||||||
from collections import UserDict
 | 
					 | 
				
			||||||
from pathlib import Path
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from .exceptions import ConfigLoaderError
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
NODEAGENT_CONFIG_FILE = os.getenv('NODEAGENT_CONFIG_FILE')
 | 
					 | 
				
			||||||
NODEAGENT_DEFAULT_CONFIG_FILE = '/etc/node-agent/config.toml'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class ConfigLoader(UserDict):
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, file: Path | None = None):
 | 
					 | 
				
			||||||
        if file is None:
 | 
					 | 
				
			||||||
            file = NODEAGENT_CONFIG_FILE or NODEAGENT_DEFAULT_CONFIG_FILE
 | 
					 | 
				
			||||||
        self.file = Path(file)
 | 
					 | 
				
			||||||
        super().__init__(self._load())
 | 
					 | 
				
			||||||
        # todo: load deafult configuration
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _load(self) -> dict:
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            with open(self.file, 'rb') as config:
 | 
					 | 
				
			||||||
                return tomllib.load(config)
 | 
					 | 
				
			||||||
                # todo: config schema validation
 | 
					 | 
				
			||||||
        except tomllib.TOMLDecodeError as tomlerr:
 | 
					 | 
				
			||||||
            raise ConfigLoaderError(
 | 
					 | 
				
			||||||
                f'Bad TOML syntax in config file: {self.file}: {tomlerr}'
 | 
					 | 
				
			||||||
            ) from tomlerr
 | 
					 | 
				
			||||||
        except (OSError, ValueError) as readerr:
 | 
					 | 
				
			||||||
            raise ConfigLoaderError(
 | 
					 | 
				
			||||||
                f'Cannot read config file: {self.file}: {readerr}') from readerr
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def reload(self) -> None:
 | 
					 | 
				
			||||||
        self.data = self._load()
 | 
					 | 
				
			||||||
@@ -1,22 +0,0 @@
 | 
				
			|||||||
class ConfigLoaderError(Exception):
 | 
					 | 
				
			||||||
    """Bad config file syntax, unreachable file or bad config schema."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class LibvirtSessionError(Exception):
 | 
					 | 
				
			||||||
    """Something went wrong while connecting to libvirtd."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class VMError(Exception):
 | 
					 | 
				
			||||||
    """Something went wrong while interacting with the domain."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class VMNotFound(VMError):
 | 
					 | 
				
			||||||
    """Virtual machine not found on node."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class GuestAgentError(Exception):
 | 
					 | 
				
			||||||
    """Mostly QEMU Guest Agent is not responding."""
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class StoragePoolError(Exception):
 | 
					 | 
				
			||||||
    """Something went wrong when operating with storage pool."""
 | 
					 | 
				
			||||||
@@ -1,52 +0,0 @@
 | 
				
			|||||||
from contextlib import AbstractContextManager
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import libvirt
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from .exceptions import LibvirtSessionError, VMNotFound
 | 
					 | 
				
			||||||
from .vm import GuestAgent, VirtualMachine
 | 
					 | 
				
			||||||
from .volume import StoragePool
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class LibvirtSession(AbstractContextManager):
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, uri: str = 'qemu:///system'):
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            self.connection = libvirt.open(uri)
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise LibvirtSessionError(err) from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __enter__(self):
 | 
					 | 
				
			||||||
        return self
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __exit__(self, exception_type, exception_value, exception_traceback):
 | 
					 | 
				
			||||||
        self.close()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_machine(self, name: str) -> VirtualMachine:
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            return VirtualMachine(self.connection.lookupByName(name))
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
 | 
					 | 
				
			||||||
                raise VMNotFound(name) from err
 | 
					 | 
				
			||||||
            raise LibvirtSessionError(err) from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def list_machines(self) -> list[VirtualMachine]:
 | 
					 | 
				
			||||||
        return [VirtualMachine(dom) for dom in
 | 
					 | 
				
			||||||
                self.connection.listAllDomains()]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_guest_agent(self, name: str,
 | 
					 | 
				
			||||||
                        timeout: int | None = None) -> GuestAgent:
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            return GuestAgent(self.connection.lookupByName(name), timeout)
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
 | 
					 | 
				
			||||||
                raise VMNotFound(name) from err
 | 
					 | 
				
			||||||
            raise LibvirtSessionError(err) from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_storage_pool(self, name: str) -> StoragePool:
 | 
					 | 
				
			||||||
        return StoragePool(self.connection.storagePoolLookupByName(name))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def list_storage_pools(self) -> list[StoragePool]:
 | 
					 | 
				
			||||||
        return [StoragePool(p) for p in self.connection.listStoragePools()]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def close(self) -> None:
 | 
					 | 
				
			||||||
        self.connection.close()
 | 
					 | 
				
			||||||
@@ -1 +0,0 @@
 | 
				
			|||||||
from . import mac, xml
 | 
					 | 
				
			||||||
@@ -1,10 +0,0 @@
 | 
				
			|||||||
import random
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
def random_mac() -> str:
 | 
					 | 
				
			||||||
    """Retrun random MAC address."""
 | 
					 | 
				
			||||||
    mac = [0x00, 0x16, 0x3e,
 | 
					 | 
				
			||||||
           random.randint(0x00, 0x7f),
 | 
					 | 
				
			||||||
           random.randint(0x00, 0xff),
 | 
					 | 
				
			||||||
           random.randint(0x00, 0xff)]
 | 
					 | 
				
			||||||
    return ':'.join(map(lambda x: "%02x" % x, mac))
 | 
					 | 
				
			||||||
@@ -1,73 +0,0 @@
 | 
				
			|||||||
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 +0,0 @@
 | 
				
			|||||||
from .guest_agent import GuestAgent
 | 
					 | 
				
			||||||
from .installer import VirtualMachineInstaller
 | 
					 | 
				
			||||||
from .virtual_machine import VirtualMachine
 | 
					 | 
				
			||||||
@@ -1,30 +0,0 @@
 | 
				
			|||||||
import libvirt
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from ..exceptions import VMError
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class VirtualMachineBase:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, domain: libvirt.virDomain):
 | 
					 | 
				
			||||||
        self.domain = domain
 | 
					 | 
				
			||||||
        self.domain_name = self._get_domain_name()
 | 
					 | 
				
			||||||
        self.domain_info = self._get_domain_info()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _get_domain_name(self):
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            return self.domain.name()
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise VMError(f'Cannot get domain name: {err}') from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _get_domain_info(self):
 | 
					 | 
				
			||||||
        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
 | 
					 | 
				
			||||||
@@ -1,179 +0,0 @@
 | 
				
			|||||||
import json
 | 
					 | 
				
			||||||
import logging
 | 
					 | 
				
			||||||
from base64 import b64decode, standard_b64encode
 | 
					 | 
				
			||||||
from time import sleep, time
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import libvirt
 | 
					 | 
				
			||||||
import libvirt_qemu
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from ..exceptions import GuestAgentError
 | 
					 | 
				
			||||||
from .base import VirtualMachineBase
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
logger = logging.getLogger(__name__)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
QEMU_TIMEOUT = 60  # in seconds
 | 
					 | 
				
			||||||
POLL_INTERVAL = 0.3  # also in seconds
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class GuestAgent(VirtualMachineBase):
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
    Interacting with QEMU guest agent. Methods:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    execute()
 | 
					 | 
				
			||||||
        Low-level method for executing QEMU command as dict. Command dict
 | 
					 | 
				
			||||||
        internally converts to JSON. See method docstring for more info.
 | 
					 | 
				
			||||||
    shellexec()
 | 
					 | 
				
			||||||
        High-level method for executing shell commands on guest. Command
 | 
					 | 
				
			||||||
        must be passed as string. Wraps execute() method.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    TODO:
 | 
					 | 
				
			||||||
        check() method. Ping guest agent and check supported commands.
 | 
					 | 
				
			||||||
    """
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def __init__(self, domain: libvirt.virDomain, timeout: int | None = None,
 | 
					 | 
				
			||||||
                 flags: int | None = None):
 | 
					 | 
				
			||||||
        super().__init__(domain)
 | 
					 | 
				
			||||||
        self.timeout = timeout or QEMU_TIMEOUT  # timeout for guest agent
 | 
					 | 
				
			||||||
        self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
 | 
					 | 
				
			||||||
        self.last_pid = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def execute(self,
 | 
					 | 
				
			||||||
                command: dict,
 | 
					 | 
				
			||||||
                stdin: str | None = None,
 | 
					 | 
				
			||||||
                capture_output: bool = False,
 | 
					 | 
				
			||||||
                decode_output: bool = False,
 | 
					 | 
				
			||||||
                wait: bool = True,
 | 
					 | 
				
			||||||
                timeout: int = QEMU_TIMEOUT
 | 
					 | 
				
			||||||
                ) -> tuple[bool | None, int | None, str | None, str | None]:
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Execute command on guest and return output if `capture_output` is True.
 | 
					 | 
				
			||||||
        See https://wiki.qemu.org/Documentation/QMP for QEMU commands reference.
 | 
					 | 
				
			||||||
        If `wait` is True poll guest command output with POLL_INTERVAL. Raise
 | 
					 | 
				
			||||||
        GuestAgentError on `timeout` reached (in seconds).
 | 
					 | 
				
			||||||
        Return values:
 | 
					 | 
				
			||||||
            tuple(
 | 
					 | 
				
			||||||
                exited: bool | None,
 | 
					 | 
				
			||||||
                exitcode: int | None,
 | 
					 | 
				
			||||||
                stdout: str | None,
 | 
					 | 
				
			||||||
                stderr: str | None
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        stdout and stderr are base64 encoded strings or None. stderr and stdout
 | 
					 | 
				
			||||||
        will be decoded if `decode_output` is True.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        # todo command dict schema validation
 | 
					 | 
				
			||||||
        if capture_output:
 | 
					 | 
				
			||||||
            command['arguments']['capture-output'] = True
 | 
					 | 
				
			||||||
        if isinstance(stdin, str):
 | 
					 | 
				
			||||||
            command['arguments']['input-data'] = standard_b64encode(
 | 
					 | 
				
			||||||
                stdin.encode('utf-8')).decode('utf-8')
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        # Execute command on guest
 | 
					 | 
				
			||||||
        cmd_out = self._execute(command)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if capture_output:
 | 
					 | 
				
			||||||
            self.last_pid = json.loads(cmd_out)['return']['pid']
 | 
					 | 
				
			||||||
            return self._get_cmd_result(
 | 
					 | 
				
			||||||
                self.last_pid,
 | 
					 | 
				
			||||||
                decode_output=decode_output,
 | 
					 | 
				
			||||||
                wait=wait,
 | 
					 | 
				
			||||||
                timeout=timeout,
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        return None, None, None, None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def shellexec(self,
 | 
					 | 
				
			||||||
                  command: str,
 | 
					 | 
				
			||||||
                  stdin: str | None = None,
 | 
					 | 
				
			||||||
                  executable: str = '/bin/sh',
 | 
					 | 
				
			||||||
                  capture_output: bool = False,
 | 
					 | 
				
			||||||
                  decode_output: bool = False,
 | 
					 | 
				
			||||||
                  wait: bool = True,
 | 
					 | 
				
			||||||
                  timeout: int = QEMU_TIMEOUT
 | 
					 | 
				
			||||||
                  ) -> tuple[bool | None, int | None, str | None, str | None]:
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Execute command on guest with selected shell. /bin/sh by default.
 | 
					 | 
				
			||||||
        Otherwise of execute() this function brings shell command as string.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        cmd = {
 | 
					 | 
				
			||||||
            'execute': 'guest-exec',
 | 
					 | 
				
			||||||
            'arguments': {
 | 
					 | 
				
			||||||
                'path': executable,
 | 
					 | 
				
			||||||
                'arg': ['-c', command],
 | 
					 | 
				
			||||||
            }
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        return self.execute(
 | 
					 | 
				
			||||||
            cmd,
 | 
					 | 
				
			||||||
            stdin=stdin,
 | 
					 | 
				
			||||||
            capture_output=capture_output,
 | 
					 | 
				
			||||||
            decode_output=decode_output,
 | 
					 | 
				
			||||||
            wait=wait,
 | 
					 | 
				
			||||||
            timeout=timeout,
 | 
					 | 
				
			||||||
        )
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def poll_pid(self, pid: int):
 | 
					 | 
				
			||||||
        # Нужно цепляться к PID и вывести результат
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _execute(self, command: dict):
 | 
					 | 
				
			||||||
        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:
 | 
					 | 
				
			||||||
            return libvirt_qemu.qemuAgentCommand(
 | 
					 | 
				
			||||||
                self.domain,  # virDomain object
 | 
					 | 
				
			||||||
                json.dumps(command),
 | 
					 | 
				
			||||||
                self.timeout,
 | 
					 | 
				
			||||||
                self.flags,
 | 
					 | 
				
			||||||
            )
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise GuestAgentError(
 | 
					 | 
				
			||||||
                f'Cannot execute command on vm={self.domain_name}: {err}'
 | 
					 | 
				
			||||||
            ) from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _get_cmd_result(
 | 
					 | 
				
			||||||
            self, pid: int, decode_output: bool = False, wait: bool = True,
 | 
					 | 
				
			||||||
            timeout: int = QEMU_TIMEOUT):
 | 
					 | 
				
			||||||
        """Get executed command result. See GuestAgent.execute() for info."""
 | 
					 | 
				
			||||||
        cmd = {'execute': 'guest-exec-status', 'arguments': {'pid': pid}}
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if not wait:
 | 
					 | 
				
			||||||
            output = json.loads(self._execute(cmd))
 | 
					 | 
				
			||||||
            return self._return_tuple(output, decode=decode_output)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        logger.debug('Start polling command pid=%s on vm=%s', pid,
 | 
					 | 
				
			||||||
                     self.domain_name)
 | 
					 | 
				
			||||||
        start_time = time()
 | 
					 | 
				
			||||||
        while True:
 | 
					 | 
				
			||||||
            output = json.loads(self._execute(cmd))
 | 
					 | 
				
			||||||
            if output['return']['exited']:
 | 
					 | 
				
			||||||
                break
 | 
					 | 
				
			||||||
            sleep(POLL_INTERVAL)
 | 
					 | 
				
			||||||
            now = time()
 | 
					 | 
				
			||||||
            if now - start_time > timeout:
 | 
					 | 
				
			||||||
                raise GuestAgentError(
 | 
					 | 
				
			||||||
                    f'Polling command pid={pid} on vm={self.domain_name} '
 | 
					 | 
				
			||||||
                    f'took longer than {timeout} seconds.'
 | 
					 | 
				
			||||||
                )
 | 
					 | 
				
			||||||
        logger.debug('Polling command pid=%s on vm=%s finished, '
 | 
					 | 
				
			||||||
                     'time taken: %s seconds',
 | 
					 | 
				
			||||||
                     pid, self.domain_name, int(time() - start_time))
 | 
					 | 
				
			||||||
        return self._return_tuple(output, decode=decode_output)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def _return_tuple(self, output: dict, decode: bool = False):
 | 
					 | 
				
			||||||
        output = output['return']
 | 
					 | 
				
			||||||
        exited = output['exited']
 | 
					 | 
				
			||||||
        exitcode = output['exitcode']
 | 
					 | 
				
			||||||
        stdout = stderr = None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if 'out-data' in output.keys():
 | 
					 | 
				
			||||||
            stdout = output['out-data']
 | 
					 | 
				
			||||||
        if 'err-data' in output.keys():
 | 
					 | 
				
			||||||
            stderr = output['err-data']
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        if decode:
 | 
					 | 
				
			||||||
            stdout = b64decode(stdout).decode('utf-8') if stdout else None
 | 
					 | 
				
			||||||
            stderr = b64decode(stderr).decode('utf-8') if stderr else None
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        return exited, exitcode, stdout, stderr
 | 
					 | 
				
			||||||
@@ -1,168 +0,0 @@
 | 
				
			|||||||
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)
 | 
					 | 
				
			||||||
@@ -1,233 +0,0 @@
 | 
				
			|||||||
import logging
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import libvirt
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from ..exceptions import VMError
 | 
					 | 
				
			||||||
from ..volume import VolumeInfo
 | 
					 | 
				
			||||||
from .base import VirtualMachineBase
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
logger = logging.getLogger(__name__)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class VirtualMachine(VirtualMachineBase):
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def name(self):
 | 
					 | 
				
			||||||
        return self.domain_name
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def status(self) -> str:
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Return VM state: 'running', 'shutoff', etc. Reference:
 | 
					 | 
				
			||||||
        https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            # libvirt returns list [state: int, reason: int]
 | 
					 | 
				
			||||||
            # https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainGetState
 | 
					 | 
				
			||||||
            state = self.domain.state()[0]
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise VMError(
 | 
					 | 
				
			||||||
                f'Cannot fetch VM status vm={self.domain_name}: {err}') from err
 | 
					 | 
				
			||||||
        STATES = {
 | 
					 | 
				
			||||||
            libvirt.VIR_DOMAIN_NOSTATE: 'nostate',
 | 
					 | 
				
			||||||
            libvirt.VIR_DOMAIN_RUNNING: 'running',
 | 
					 | 
				
			||||||
            libvirt.VIR_DOMAIN_BLOCKED: 'blocked',
 | 
					 | 
				
			||||||
            libvirt.VIR_DOMAIN_PAUSED: 'paused',
 | 
					 | 
				
			||||||
            libvirt.VIR_DOMAIN_SHUTDOWN: 'shutdown',
 | 
					 | 
				
			||||||
            libvirt.VIR_DOMAIN_SHUTOFF: 'shutoff',
 | 
					 | 
				
			||||||
            libvirt.VIR_DOMAIN_CRASHED: 'crashed',
 | 
					 | 
				
			||||||
            libvirt.VIR_DOMAIN_PMSUSPENDED: 'pmsuspended',
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        return STATES.get(state)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def is_running(self) -> bool:
 | 
					 | 
				
			||||||
        """Return True if VM is running, else return False."""
 | 
					 | 
				
			||||||
        if self.domain.isActive() != 1:
 | 
					 | 
				
			||||||
            # inactive (0) or error (-1)
 | 
					 | 
				
			||||||
            return False
 | 
					 | 
				
			||||||
        return True
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def is_autostart(self) -> bool:
 | 
					 | 
				
			||||||
        """Return True if VM autostart is enabled, else return False."""
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            if self.domain.autostart() == 1:
 | 
					 | 
				
			||||||
                return True
 | 
					 | 
				
			||||||
            return False
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise VMError(
 | 
					 | 
				
			||||||
                f'Cannot get autostart status vm={self.domain_name}: {err}'
 | 
					 | 
				
			||||||
            ) from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def start(self) -> None:
 | 
					 | 
				
			||||||
        """Start defined VM."""
 | 
					 | 
				
			||||||
        logger.info('Starting VM: vm=%s', self.domain_name)
 | 
					 | 
				
			||||||
        if self.is_running:
 | 
					 | 
				
			||||||
            logger.warning('VM vm=%s is already started, nothing to do',
 | 
					 | 
				
			||||||
                           self.domain_name)
 | 
					 | 
				
			||||||
            return
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            self.domain.create()
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise VMError(
 | 
					 | 
				
			||||||
                f'Cannot start vm={self.domain_name}: {err}') from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def shutdown(self, method: str | None = None) -> None:
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Send signal to guest OS to shutdown. Supports several modes:
 | 
					 | 
				
			||||||
        * GUEST_AGENT - use guest agent
 | 
					 | 
				
			||||||
        * NORMAL - use method choosen by hypervisor to shutdown machine
 | 
					 | 
				
			||||||
        * SIGTERM - send SIGTERM to QEMU process, destroy machine gracefully
 | 
					 | 
				
			||||||
        * SIGKILL - send SIGKILL to QEMU process. May corrupt guest data!
 | 
					 | 
				
			||||||
        If mode is not passed use 'NORMAL' mode.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        METHODS = {
 | 
					 | 
				
			||||||
            'GUEST_AGENT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
 | 
					 | 
				
			||||||
            'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT,
 | 
					 | 
				
			||||||
            'SIGTERM': libvirt.VIR_DOMAIN_DESTROY_GRACEFUL,
 | 
					 | 
				
			||||||
            'SIGKILL': libvirt.VIR_DOMAIN_DESTROY_DEFAULT
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
        if method is None:
 | 
					 | 
				
			||||||
            method = 'NORMAL'
 | 
					 | 
				
			||||||
        if not isinstance(method, str):
 | 
					 | 
				
			||||||
            raise ValueError(f"Mode must be a 'str', not {type(method)}")
 | 
					 | 
				
			||||||
        if method.upper() not in METHODS:
 | 
					 | 
				
			||||||
            raise ValueError(f"Unsupported mode: '{method}'")
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            if method in ['GUEST_AGENT', 'NORMAL']:
 | 
					 | 
				
			||||||
                self.domain.shutdownFlags(flags=METHODS.get(method))
 | 
					 | 
				
			||||||
            elif method in ['SIGTERM', 'SIGKILL']:
 | 
					 | 
				
			||||||
                self.domain.destroyFlags(flags=METHODS.get(method))
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise VMError(f'Cannot shutdown vm={self.domain_name} with '
 | 
					 | 
				
			||||||
                          f'method={method}: {err}') from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def reset(self) -> None:
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Copypaste from libvirt doc:
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Reset a domain immediately without any guest OS shutdown.
 | 
					 | 
				
			||||||
        Reset emulates the power reset button on a machine, where all
 | 
					 | 
				
			||||||
        hardware sees the RST line set and reinitializes internal state.
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
        Note that there is a risk of data loss caused by reset without any
 | 
					 | 
				
			||||||
        guest OS shutdown.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            self.domain.reset()
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise VMError(
 | 
					 | 
				
			||||||
                f'Cannot reset vm={self.domain_name}: {err}') from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def reboot(self) -> None:
 | 
					 | 
				
			||||||
        """Send ACPI signal to guest OS to reboot. OS may ignore this."""
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            self.domain.reboot()
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise VMError(
 | 
					 | 
				
			||||||
                f'Cannot reboot vm={self.domain_name}: {err}') from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def set_autostart(self, enable: bool) -> None:
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Configure VM to be automatically started when the host machine boots.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        if enable:
 | 
					 | 
				
			||||||
            autostart_flag = 1
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            autostart_flag = 0
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            self.domain.setAutostart(autostart_flag)
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise VMError(f'Cannot set autostart vm={self.domain_name} '
 | 
					 | 
				
			||||||
                          f'autostart={autostart_flag}: {err}') from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    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.setMemoryFlags(memory * 1024,
 | 
					 | 
				
			||||||
                                       libvirt.VIR_DOMAIN_MEM_MAXIMUM)
 | 
					 | 
				
			||||||
            self.domain.setMemoryFlags(memory * 1024, flags=flags)
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            raise VMError(
 | 
					 | 
				
			||||||
                f'Cannot set memory for vm={self.domain_name} {memory=}: {err}') from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def attach_device(self, device_info: 'DeviceInfo', hotplug: bool = False):
 | 
					 | 
				
			||||||
        if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
 | 
					 | 
				
			||||||
            flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
 | 
					 | 
				
			||||||
                     libvirt.VIR_DOMAIN_AFFECT_CONFIG)
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
					 | 
				
			||||||
        self.domain.attachDeviceFlags(device_info.to_xml(), flags=flags)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def detach_device(self, device_info: 'DeviceInfo', hotplug: bool = False):
 | 
					 | 
				
			||||||
        if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
 | 
					 | 
				
			||||||
            flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
 | 
					 | 
				
			||||||
                     libvirt.VIR_DOMAIN_AFFECT_CONFIG)
 | 
					 | 
				
			||||||
        else:
 | 
					 | 
				
			||||||
            flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
					 | 
				
			||||||
        self.domain.detachDeviceFlags(device_info.to_xml(), flags=flags)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def resize_volume(self, vol_info: VolumeInfo, online: bool = False):
 | 
					 | 
				
			||||||
        # Этот метод должен принимать описание волюма и в зависимости от
 | 
					 | 
				
			||||||
        # флага online вызывать virStorageVolResize или virDomainBlockResize
 | 
					 | 
				
			||||||
        # https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainBlockResize
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def list_ssh_keys(self, user: str):
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def set_ssh_keys(self, user: str):
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def remove_ssh_keys(self, user: str):
 | 
					 | 
				
			||||||
        pass
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def set_user_password(self, user: str, password: str) -> None:
 | 
					 | 
				
			||||||
        self.domain.setUserPassword(user, password)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def dump_xml(self) -> str:
 | 
					 | 
				
			||||||
        return self.domain.XMLDesc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def delete(self, delete_volumes: bool = False) -> None:
 | 
					 | 
				
			||||||
        """Undefine VM."""
 | 
					 | 
				
			||||||
        self.shutdown(method='SIGTERM')
 | 
					 | 
				
			||||||
        self.domain.undefine()
 | 
					 | 
				
			||||||
        # todo: delete local volumes
 | 
					 | 
				
			||||||
@@ -1,2 +0,0 @@
 | 
				
			|||||||
from .storage_pool import StoragePool
 | 
					 | 
				
			||||||
from .volume import DiskInfo, Volume, VolumeInfo
 | 
					 | 
				
			||||||
@@ -1,70 +0,0 @@
 | 
				
			|||||||
import logging
 | 
					 | 
				
			||||||
from collections import namedtuple
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import libvirt
 | 
					 | 
				
			||||||
from lxml import etree
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
from ..exceptions import StoragePoolError
 | 
					 | 
				
			||||||
from .volume import Volume, VolumeInfo
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
logger = logging.getLogger(__name__)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
class StoragePool:
 | 
					 | 
				
			||||||
    def __init__(self, pool: libvirt.virStoragePool):
 | 
					 | 
				
			||||||
        self.pool = pool
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def name(self) -> str:
 | 
					 | 
				
			||||||
        return self.pool.name()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def path(self) -> str:
 | 
					 | 
				
			||||||
        xml = etree.fromstring(self.pool.XMLDesc())
 | 
					 | 
				
			||||||
        return xml.xpath('/pool/target/path/text()')[0]
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def usage(self) -> 'StoragePoolUsage':
 | 
					 | 
				
			||||||
        xml = etree.fromstring(self.pool.XMLDesc())
 | 
					 | 
				
			||||||
        StoragePoolUsage = namedtuple('StoagePoolUsage',
 | 
					 | 
				
			||||||
                                      ['capacity', 'allocation', 'available'])
 | 
					 | 
				
			||||||
        return StoragePoolUsage(
 | 
					 | 
				
			||||||
            capacity=int(xml.xpath('/pool/capacity/text()')[0])
 | 
					 | 
				
			||||||
            allocation=int(xml.xpath('/pool/allocation/text()')[0])
 | 
					 | 
				
			||||||
            available=int(xml.xpath('/pool/available/text()')[0]))
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def dump_xml(self) -> str:
 | 
					 | 
				
			||||||
        return self.pool.XMLDesc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def refresh(self) -> None:
 | 
					 | 
				
			||||||
        self.pool.refresh()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def create_volume(self, vol_info: VolumeInfo) -> Volume:
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        Create storage volume and return Volume instance.
 | 
					 | 
				
			||||||
        """
 | 
					 | 
				
			||||||
        logger.info('Create storage volume vol=%s in pool=%s',
 | 
					 | 
				
			||||||
                    vol_info.name, self.pool)
 | 
					 | 
				
			||||||
        vol = self.pool.createXML(
 | 
					 | 
				
			||||||
            vol_info.to_xml(),
 | 
					 | 
				
			||||||
            flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA)
 | 
					 | 
				
			||||||
        return Volume(self.pool, vol)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def get_volume(self, name: str) -> Volume | None:
 | 
					 | 
				
			||||||
        """Lookup and return Volume instance or None."""
 | 
					 | 
				
			||||||
        logger.info('Lookup for storage volume vol=%s in pool=%s',
 | 
					 | 
				
			||||||
                    name, self.pool.name)
 | 
					 | 
				
			||||||
        try:
 | 
					 | 
				
			||||||
            vol = self.pool.storageVolLookupByName(name)
 | 
					 | 
				
			||||||
            return Volume(self.pool, vol)
 | 
					 | 
				
			||||||
        except libvirt.libvirtError as err:
 | 
					 | 
				
			||||||
            if (err.get_error_domain() == libvirt.VIR_FROM_STORAGE or
 | 
					 | 
				
			||||||
                    err.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL):
 | 
					 | 
				
			||||||
                logger.error(err.get_error_message())
 | 
					 | 
				
			||||||
                return None
 | 
					 | 
				
			||||||
            logger.error('libvirt error: %s' err)
 | 
					 | 
				
			||||||
            raise StoragePoolError(f'libvirt error: {err}') from err
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def list_volumes(self) -> list[Volume]:
 | 
					 | 
				
			||||||
        return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()]
 | 
					 | 
				
			||||||
@@ -1,80 +0,0 @@
 | 
				
			|||||||
from dataclasses import dataclass
 | 
					 | 
				
			||||||
from time import time
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
import libvirt
 | 
					 | 
				
			||||||
from lxml import etree
 | 
					 | 
				
			||||||
from lxml.builder import E
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
@dataclass
 | 
					 | 
				
			||||||
class VolumeInfo:
 | 
					 | 
				
			||||||
    name: str
 | 
					 | 
				
			||||||
    path: str
 | 
					 | 
				
			||||||
    capacity: int
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def to_xml(self) -> str:
 | 
					 | 
				
			||||||
        unixtime = str(int(time()))
 | 
					 | 
				
			||||||
        xml = E.volume(type='file')
 | 
					 | 
				
			||||||
        xml.append(E.name(self.name))
 | 
					 | 
				
			||||||
        xml.append(E.key(self.path))
 | 
					 | 
				
			||||||
        xml.append(E.source())
 | 
					 | 
				
			||||||
        xml.append(E.capacity(str(self.capacity * 1024 * 1024), unit='bytes'))
 | 
					 | 
				
			||||||
        xml.append(E.allocation('0'))
 | 
					 | 
				
			||||||
        xml.append(E.target(
 | 
					 | 
				
			||||||
            E.path(self.path),
 | 
					 | 
				
			||||||
            E.format(type='qcow2'),
 | 
					 | 
				
			||||||
            E.timestamps(
 | 
					 | 
				
			||||||
                E.atime(unixtime),
 | 
					 | 
				
			||||||
                E.mtime(unixtime),
 | 
					 | 
				
			||||||
                E.ctime(unixtime)),
 | 
					 | 
				
			||||||
            E.compat('1.1'),
 | 
					 | 
				
			||||||
            E.features(E.lazy_refcounts())
 | 
					 | 
				
			||||||
        ))
 | 
					 | 
				
			||||||
        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:
 | 
					 | 
				
			||||||
    def __init__(self, pool: libvirt.virStoragePool,
 | 
					 | 
				
			||||||
                 vol: libvirt.virStorageVol):
 | 
					 | 
				
			||||||
        self.pool = pool
 | 
					 | 
				
			||||||
        self.vol = vol
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def name(self) -> str:
 | 
					 | 
				
			||||||
        return self.vol.name()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    @property
 | 
					 | 
				
			||||||
    def path(self) -> str:
 | 
					 | 
				
			||||||
        return self.vol.path()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def dump_xml(self) -> str:
 | 
					 | 
				
			||||||
        return self.vol.XMLDesc()
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def clone(self, vol_info: VolumeInfo) -> None:
 | 
					 | 
				
			||||||
        self.pool.createXMLFrom(
 | 
					 | 
				
			||||||
            vol_info.to_xml(),
 | 
					 | 
				
			||||||
            self.vol,
 | 
					 | 
				
			||||||
            flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def resize(self, capacity: int):
 | 
					 | 
				
			||||||
        """Resize volume to `capacity`. Unit is mebibyte."""
 | 
					 | 
				
			||||||
        self.vol.resize(capacity * 1024 * 1024)
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    def delete(self) -> None:
 | 
					 | 
				
			||||||
        self.vol.delete()
 | 
					 | 
				
			||||||
							
								
								
									
										29
									
								
								config.toml
									
									
									
									
									
								
							
							
						
						
									
										29
									
								
								config.toml
									
									
									
									
									
								
							@@ -1,29 +0,0 @@
 | 
				
			|||||||
[libvirt]
 | 
					 | 
				
			||||||
uri = 'qemu:///system'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[logging]
 | 
					 | 
				
			||||||
level = 'INFO'
 | 
					 | 
				
			||||||
driver = 'file'
 | 
					 | 
				
			||||||
file = '/var/log/compute/compute.log'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[[storages.pools]]
 | 
					 | 
				
			||||||
name = 'ssd-nvme'
 | 
					 | 
				
			||||||
enabled = true
 | 
					 | 
				
			||||||
default = true
 | 
					 | 
				
			||||||
path = '/vm-volumes/ssd-nvme'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[[storages.pools]]
 | 
					 | 
				
			||||||
name = 'hdd'
 | 
					 | 
				
			||||||
enabled = true
 | 
					 | 
				
			||||||
path = '/vm-volumes/hdd'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[[storages.pools]]
 | 
					 | 
				
			||||||
name = 'images'
 | 
					 | 
				
			||||||
enabled = true
 | 
					 | 
				
			||||||
path = '/vm-images/vendor'
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
[virtual_machine.defaults]
 | 
					 | 
				
			||||||
autostart = true
 | 
					 | 
				
			||||||
start = true
 | 
					 | 
				
			||||||
cpu_vendor = 'Intel'
 | 
					 | 
				
			||||||
cpu_model = 'Broadwell'
 | 
					 | 
				
			||||||
							
								
								
									
										20
									
								
								docs/Makefile
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										20
									
								
								docs/Makefile
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,20 @@
 | 
				
			|||||||
 | 
					# Minimal makefile for Sphinx documentation
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# You can set these variables from the command line, and also
 | 
				
			||||||
 | 
					# from the environment for the first two.
 | 
				
			||||||
 | 
					SPHINXOPTS    ?=
 | 
				
			||||||
 | 
					SPHINXBUILD   ?= sphinx-build
 | 
				
			||||||
 | 
					SOURCEDIR     = source
 | 
				
			||||||
 | 
					BUILDDIR      = build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Put it first so that "make" without argument is like "make help".
 | 
				
			||||||
 | 
					help:
 | 
				
			||||||
 | 
						@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.PHONY: help Makefile
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Catch-all target: route all unknown targets to Sphinx using the new
 | 
				
			||||||
 | 
					# "make mode" option.  $(O) is meant as a shortcut for $(SPHINXOPTS).
 | 
				
			||||||
 | 
					%: Makefile
 | 
				
			||||||
 | 
						@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
 | 
				
			||||||
							
								
								
									
										35
									
								
								docs/make.bat
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								docs/make.bat
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					@ECHO OFF
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					pushd %~dp0
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					REM Command file for Sphinx documentation
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if "%SPHINXBUILD%" == "" (
 | 
				
			||||||
 | 
						set SPHINXBUILD=sphinx-build
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					set SOURCEDIR=source
 | 
				
			||||||
 | 
					set BUILDDIR=build
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					%SPHINXBUILD% >NUL 2>NUL
 | 
				
			||||||
 | 
					if errorlevel 9009 (
 | 
				
			||||||
 | 
						echo.
 | 
				
			||||||
 | 
						echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
 | 
				
			||||||
 | 
						echo.installed, then set the SPHINXBUILD environment variable to point
 | 
				
			||||||
 | 
						echo.to the full path of the 'sphinx-build' executable. Alternatively you
 | 
				
			||||||
 | 
						echo.may add the Sphinx directory to PATH.
 | 
				
			||||||
 | 
						echo.
 | 
				
			||||||
 | 
						echo.If you don't have Sphinx installed, grab it from
 | 
				
			||||||
 | 
						echo.https://www.sphinx-doc.org/
 | 
				
			||||||
 | 
						exit /b 1
 | 
				
			||||||
 | 
					)
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					if "%1" == "" goto help
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
 | 
				
			||||||
 | 
					goto end
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					:help
 | 
				
			||||||
 | 
					%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					:end
 | 
				
			||||||
 | 
					popd
 | 
				
			||||||
							
								
								
									
										8
									
								
								docs/source/_templates/versioning.html
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								docs/source/_templates/versioning.html
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,8 @@
 | 
				
			|||||||
 | 
					{% if versions %}
 | 
				
			||||||
 | 
					<h3>{{ _('Versions') }}</h3>
 | 
				
			||||||
 | 
					<ul>
 | 
				
			||||||
 | 
					  {%- for item in versions %}
 | 
				
			||||||
 | 
					  <li><a href="{{ item.url }}">{{ item.name }}</a></li>
 | 
				
			||||||
 | 
					  {%- endfor %}
 | 
				
			||||||
 | 
					</ul>
 | 
				
			||||||
 | 
					{% endif %}
 | 
				
			||||||
							
								
								
									
										35
									
								
								docs/source/conf.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										35
									
								
								docs/source/conf.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,35 @@
 | 
				
			|||||||
 | 
					# Configuration file for the Sphinx documentation builder.
 | 
				
			||||||
 | 
					#
 | 
				
			||||||
 | 
					# For the full list of built-in configuration values, see the documentation:
 | 
				
			||||||
 | 
					# https://www.sphinx-doc.org/en/master/usage/configuration.html
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# -- Project information -----------------------------------------------------
 | 
				
			||||||
 | 
					# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					project = 'Compute'
 | 
				
			||||||
 | 
					copyright = '2023, Compute Authors'
 | 
				
			||||||
 | 
					author = 'Compute Authors'
 | 
				
			||||||
 | 
					release = '0.1.0'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# -- General configuration ---------------------------------------------------
 | 
				
			||||||
 | 
					# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					extensions = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					templates_path = ['_templates']
 | 
				
			||||||
 | 
					exclude_patterns = []
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					language = 'ru'
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					extensions = [
 | 
				
			||||||
 | 
					    "sphinx_multiversion",
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# -- Options for HTML output -------------------------------------------------
 | 
				
			||||||
 | 
					# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					html_theme = 'alabaster'
 | 
				
			||||||
 | 
					html_static_path = ['_static']
 | 
				
			||||||
 | 
					html_sidebars = [
 | 
				
			||||||
 | 
					    "versioning.html",
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
							
								
								
									
										15
									
								
								docs/source/index.rst
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										15
									
								
								docs/source/index.rst
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,15 @@
 | 
				
			|||||||
 | 
					Compute Service
 | 
				
			||||||
 | 
					===============
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Документация библиотеки для управления Compute-инстансами.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					.. toctree::
 | 
				
			||||||
 | 
					   :maxdepth: 2
 | 
				
			||||||
 | 
					   :caption: Contents:
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					Индексы и таблицы
 | 
				
			||||||
 | 
					-----------------
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					* :ref:`genindex`
 | 
				
			||||||
 | 
					* :ref:`modindex`
 | 
				
			||||||
 | 
					* :ref:`search`
 | 
				
			||||||
							
								
								
									
										716
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										716
									
								
								poetry.lock
									
									
									
										generated
									
									
									
								
							@@ -1,16 +1,231 @@
 | 
				
			|||||||
# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
 | 
					# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "docopt"
 | 
					name = "alabaster"
 | 
				
			||||||
version = "0.6.2"
 | 
					version = "0.7.13"
 | 
				
			||||||
description = "Pythonic argument parser, that will make you smile"
 | 
					description = "A configurable sidebar-enabled Sphinx theme"
 | 
				
			||||||
category = "main"
 | 
					category = "dev"
 | 
				
			||||||
optional = false
 | 
					optional = false
 | 
				
			||||||
python-versions = "*"
 | 
					python-versions = ">=3.6"
 | 
				
			||||||
files = [
 | 
					files = [
 | 
				
			||||||
    {file = "docopt-0.6.2.tar.gz", hash = "sha256:49b3a825280bd66b3aa83585ef59c4a8c82f2c8a522dbe754a8bc8d08c85c491"},
 | 
					    {file = "alabaster-0.7.13-py3-none-any.whl", hash = "sha256:1ee19aca801bbabb5ba3f5f258e4422dfa86f82f3e9cefb0859b283cdd7f62a3"},
 | 
				
			||||||
 | 
					    {file = "alabaster-0.7.13.tar.gz", hash = "sha256:a27a4a084d5e690e16e01e03ad2b2e552c61a65469419b907243193de1a84ae2"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "babel"
 | 
				
			||||||
 | 
					version = "2.13.1"
 | 
				
			||||||
 | 
					description = "Internationalization utilities"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "Babel-2.13.1-py3-none-any.whl", hash = "sha256:7077a4984b02b6727ac10f1f7294484f737443d7e2e66c5e4380e41a3ae0b4ed"},
 | 
				
			||||||
 | 
					    {file = "Babel-2.13.1.tar.gz", hash = "sha256:33e0952d7dd6374af8dbf6768cc4ddf3ccfefc244f9986d4074704f2fbd18900"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					setuptools = {version = "*", markers = "python_version >= \"3.12\""}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					dev = ["freezegun (>=1.0,<2.0)", "pytest (>=6.0)", "pytest-cov"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "certifi"
 | 
				
			||||||
 | 
					version = "2023.7.22"
 | 
				
			||||||
 | 
					description = "Python package for providing Mozilla's CA Bundle."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.6"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "certifi-2023.7.22-py3-none-any.whl", hash = "sha256:92d6037539857d8206b8f6ae472e8b77db8058fec5937a1ef3f54304089edbb9"},
 | 
				
			||||||
 | 
					    {file = "certifi-2023.7.22.tar.gz", hash = "sha256:539cc1d13202e33ca466e88b2807e29f4c13049d6d87031a3c110744495cb082"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "charset-normalizer"
 | 
				
			||||||
 | 
					version = "3.3.2"
 | 
				
			||||||
 | 
					description = "The Real First Universal Charset Detector. Open, modern and actively maintained alternative to Chardet."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7.0"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "charset-normalizer-3.3.2.tar.gz", hash = "sha256:f30c3cb33b24454a82faecaf01b19c18562b1e89558fb6c56de4d9118a032fd5"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:25baf083bf6f6b341f4121c2f3c548875ee6f5339300e08be3f2b2ba1721cdd3"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:06435b539f889b1f6f4ac1758871aae42dc3a8c0e24ac9e60c2384973ad73027"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:9063e24fdb1e498ab71cb7419e24622516c4a04476b17a2dab57e8baa30d6e03"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:6897af51655e3691ff853668779c7bad41579facacf5fd7253b0133308cf000d"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d3193f4a680c64b4b6a9115943538edb896edc190f0b222e73761716519268e"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd70574b12bb8a4d2aaa0094515df2463cb429d8536cfb6c7ce983246983e5a6"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8465322196c8b4d7ab6d1e049e4c5cb460d0394da4a27d23cc242fbf0034b6b5"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a9a8e9031d613fd2009c182b69c7b2c1ef8239a0efb1df3f7c8da66d5dd3d537"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:beb58fe5cdb101e3a055192ac291b7a21e3b7ef4f67fa1d74e331a7f2124341c"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:e06ed3eb3218bc64786f7db41917d4e686cc4856944f53d5bdf83a6884432e12"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_ppc64le.whl", hash = "sha256:2e81c7b9c8979ce92ed306c249d46894776a909505d8f5a4ba55b14206e3222f"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_s390x.whl", hash = "sha256:572c3763a264ba47b3cf708a44ce965d98555f618ca42c926a9c1616d8f34269"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:fd1abc0d89e30cc4e02e4064dc67fcc51bd941eb395c502aac3ec19fab46b519"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-win32.whl", hash = "sha256:3d47fa203a7bd9c5b6cee4736ee84ca03b8ef23193c0d1ca99b5089f72645c73"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp310-cp310-win_amd64.whl", hash = "sha256:10955842570876604d404661fbccbc9c7e684caf432c09c715ec38fbae45ae09"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:802fe99cca7457642125a8a88a084cef28ff0cf9407060f7b93dca5aa25480db"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:573f6eac48f4769d667c4442081b1794f52919e7edada77495aaed9236d13a96"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:549a3a73da901d5bc3ce8d24e0600d1fa85524c10287f6004fbab87672bf3e1e"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f27273b60488abe721a075bcca6d7f3964f9f6f067c8c4c605743023d7d3944f"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1ceae2f17a9c33cb48e3263960dc5fc8005351ee19db217e9b1bb15d28c02574"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:65f6f63034100ead094b8744b3b97965785388f308a64cf8d7c34f2f2e5be0c4"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:753f10e867343b4511128c6ed8c82f7bec3bd026875576dfd88483c5c73b2fd8"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:4a78b2b446bd7c934f5dcedc588903fb2f5eec172f3d29e52a9096a43722adfc"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e537484df0d8f426ce2afb2d0f8e1c3d0b114b83f8850e5f2fbea0e797bd82ae"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:eb6904c354526e758fda7167b33005998fb68c46fbc10e013ca97f21ca5c8887"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_ppc64le.whl", hash = "sha256:deb6be0ac38ece9ba87dea880e438f25ca3eddfac8b002a2ec3d9183a454e8ae"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_s390x.whl", hash = "sha256:4ab2fe47fae9e0f9dee8c04187ce5d09f48eabe611be8259444906793ab7cbce"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:80402cd6ee291dcb72644d6eac93785fe2c8b9cb30893c1af5b8fdd753b9d40f"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-win32.whl", hash = "sha256:7cd13a2e3ddeed6913a65e66e94b51d80a041145a026c27e6bb76c31a853c6ab"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp311-cp311-win_amd64.whl", hash = "sha256:663946639d296df6a2bb2aa51b60a2454ca1cb29835324c640dafb5ff2131a77"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:0b2b64d2bb6d3fb9112bafa732def486049e63de9618b5843bcdd081d8144cd8"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:ddbb2551d7e0102e7252db79ba445cdab71b26640817ab1e3e3648dad515003b"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:55086ee1064215781fff39a1af09518bc9255b50d6333f2e4c74ca09fac6a8f6"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8f4a014bc36d3c57402e2977dada34f9c12300af536839dc38c0beab8878f38a"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a10af20b82360ab00827f916a6058451b723b4e65030c5a18577c8b2de5b3389"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8d756e44e94489e49571086ef83b2bb8ce311e730092d2c34ca8f7d925cb20aa"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:90d558489962fd4918143277a773316e56c72da56ec7aa3dc3dbbe20fdfed15b"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:6ac7ffc7ad6d040517be39eb591cac5ff87416c2537df6ba3cba3bae290c0fed"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:7ed9e526742851e8d5cc9e6cf41427dfc6068d4f5a3bb03659444b4cabf6bc26"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:8bdb58ff7ba23002a4c5808d608e4e6c687175724f54a5dade5fa8c67b604e4d"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_ppc64le.whl", hash = "sha256:6b3251890fff30ee142c44144871185dbe13b11bab478a88887a639655be1068"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_s390x.whl", hash = "sha256:b4a23f61ce87adf89be746c8a8974fe1c823c891d8f86eb218bb957c924bb143"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:efcb3f6676480691518c177e3b465bcddf57cea040302f9f4e6e191af91174d4"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-win32.whl", hash = "sha256:d965bba47ddeec8cd560687584e88cf699fd28f192ceb452d1d7ee807c5597b7"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp312-cp312-win_amd64.whl", hash = "sha256:96b02a3dc4381e5494fad39be677abcb5e6634bf7b4fa83a6dd3112607547001"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:95f2a5796329323b8f0512e09dbb7a1860c46a39da62ecb2324f116fa8fdc85c"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:c002b4ffc0be611f0d9da932eb0f704fe2602a9a949d1f738e4c34c75b0863d5"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:a981a536974bbc7a512cf44ed14938cf01030a99e9b3a06dd59578882f06f985"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:3287761bc4ee9e33561a7e058c72ac0938c4f57fe49a09eae428fd88aafe7bb6"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:42cb296636fcc8b0644486d15c12376cb9fa75443e00fb25de0b8602e64c1714"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:0a55554a2fa0d408816b3b5cedf0045f4b8e1a6065aec45849de2d6f3f8e9786"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:c083af607d2515612056a31f0a8d9e0fcb5876b7bfc0abad3ecd275bc4ebc2d5"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:87d1351268731db79e0f8e745d92493ee2841c974128ef629dc518b937d9194c"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_ppc64le.whl", hash = "sha256:bd8f7df7d12c2db9fab40bdd87a7c09b1530128315d047a086fa3ae3435cb3a8"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_s390x.whl", hash = "sha256:c180f51afb394e165eafe4ac2936a14bee3eb10debc9d9e4db8958fe36afe711"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:8c622a5fe39a48f78944a87d4fb8a53ee07344641b0562c540d840748571b811"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-win32.whl", hash = "sha256:db364eca23f876da6f9e16c9da0df51aa4f104a972735574842618b8c6d999d4"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp37-cp37m-win_amd64.whl", hash = "sha256:86216b5cee4b06df986d214f664305142d9c76df9b6512be2738aa72a2048f99"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:6463effa3186ea09411d50efc7d85360b38d5f09b870c48e4600f63af490e56a"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:6c4caeef8fa63d06bd437cd4bdcf3ffefe6738fb1b25951440d80dc7df8c03ac"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:37e55c8e51c236f95b033f6fb391d7d7970ba5fe7ff453dad675e88cf303377a"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:fb69256e180cb6c8a894fee62b3afebae785babc1ee98b81cdf68bbca1987f33"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ae5f4161f18c61806f411a13b0310bea87f987c7d2ecdbdaad0e94eb2e404238"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b2b0a0c0517616b6869869f8c581d4eb2dd83a4d79e0ebcb7d373ef9956aeb0a"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:45485e01ff4d3630ec0d9617310448a8702f70e9c01906b0d0118bdf9d124cf2"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eb00ed941194665c332bf8e078baf037d6c35d7c4f3102ea2d4f16ca94a26dc8"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:2127566c664442652f024c837091890cb1942c30937add288223dc895793f898"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:a50aebfa173e157099939b17f18600f72f84eed3049e743b68ad15bd69b6bf99"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_ppc64le.whl", hash = "sha256:4d0d1650369165a14e14e1e47b372cfcb31d6ab44e6e33cb2d4e57265290044d"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_s390x.whl", hash = "sha256:923c0c831b7cfcb071580d3f46c4baf50f174be571576556269530f4bbd79d04"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:06a81e93cd441c56a9b65d8e1d043daeb97a3d0856d177d5c90ba85acb3db087"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-win32.whl", hash = "sha256:6ef1d82a3af9d3eecdba2321dc1b3c238245d890843e040e41e470ffa64c3e25"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp38-cp38-win_amd64.whl", hash = "sha256:eb8821e09e916165e160797a6c17edda0679379a4be5c716c260e836e122f54b"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:c235ebd9baae02f1b77bcea61bce332cb4331dc3617d254df3323aa01ab47bd4"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:5b4c145409bef602a690e7cfad0a15a55c13320ff7a3ad7ca59c13bb8ba4d45d"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:68d1f8a9e9e37c1223b656399be5d6b448dea850bed7d0f87a8311f1ff3dabb0"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:22afcb9f253dac0696b5a4be4a1c0f8762f8239e21b99680099abd9b2b1b2269"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e27ad930a842b4c5eb8ac0016b0a54f5aebbe679340c26101df33424142c143c"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1f79682fbe303db92bc2b1136016a38a42e835d932bab5b3b1bfcfbf0640e519"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b261ccdec7821281dade748d088bb6e9b69e6d15b30652b74cbbac25e280b796"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:122c7fa62b130ed55f8f285bfd56d5f4b4a5b503609d181f9ad85e55c89f4185"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:d0eccceffcb53201b5bfebb52600a5fb483a20b61da9dbc885f8b103cbe7598c"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9f96df6923e21816da7e0ad3fd47dd8f94b2a5ce594e00677c0013018b813458"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_ppc64le.whl", hash = "sha256:7f04c839ed0b6b98b1a7501a002144b76c18fb1c1850c8b98d458ac269e26ed2"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_s390x.whl", hash = "sha256:34d1c8da1e78d2e001f363791c98a272bb734000fcef47a491c1e3b0505657a8"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:ff8fa367d09b717b2a17a052544193ad76cd49979c805768879cb63d9ca50561"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-win32.whl", hash = "sha256:aed38f6e4fb3f5d6bf81bfa990a07806be9d83cf7bacef998ab1a9bd660a581f"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-cp39-cp39-win_amd64.whl", hash = "sha256:b01b88d45a6fcb69667cd6d2f7a9aeb4bf53760d7fc536bf679ec94fe9f3ff3d"},
 | 
				
			||||||
 | 
					    {file = "charset_normalizer-3.3.2-py3-none-any.whl", hash = "sha256:3e4d1f6587322d2788836a99c69062fbb091331ec940e02d12d179c1d53e25fc"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "colorama"
 | 
				
			||||||
 | 
					version = "0.4.6"
 | 
				
			||||||
 | 
					description = "Cross-platform colored terminal text."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6"},
 | 
				
			||||||
 | 
					    {file = "colorama-0.4.6.tar.gz", hash = "sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "docutils"
 | 
				
			||||||
 | 
					version = "0.20.1"
 | 
				
			||||||
 | 
					description = "Docutils -- Python Documentation Utilities"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "docutils-0.20.1-py3-none-any.whl", hash = "sha256:96f387a2c5562db4476f09f13bbab2192e764cac08ebbf3a34a95d9b1e4a59d6"},
 | 
				
			||||||
 | 
					    {file = "docutils-0.20.1.tar.gz", hash = "sha256:f08a4e276c3a1583a86dce3e34aba3fe04d02bba2dd51ed16106244e8a923e3b"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "idna"
 | 
				
			||||||
 | 
					version = "3.4"
 | 
				
			||||||
 | 
					description = "Internationalized Domain Names in Applications (IDNA)"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.5"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "idna-3.4-py3-none-any.whl", hash = "sha256:90b77e79eaa3eba6de819a0c442c0b4ceefc341a7a2ab77d7562bf49f425c5c2"},
 | 
				
			||||||
 | 
					    {file = "idna-3.4.tar.gz", hash = "sha256:814f528e8dead7d329833b91c5faa87d60bf71824cd12a7530b5526063d02cb4"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "imagesize"
 | 
				
			||||||
 | 
					version = "1.4.1"
 | 
				
			||||||
 | 
					description = "Getting image size from png/jpeg/jpeg2000/gif file"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "imagesize-1.4.1-py2.py3-none-any.whl", hash = "sha256:0d8d18d08f840c19d0ee7ca1fd82490fdc3729b7ac93f49870406ddde8ef8d8b"},
 | 
				
			||||||
 | 
					    {file = "imagesize-1.4.1.tar.gz", hash = "sha256:69150444affb9cb0d5cc5a92b3676f0b2fb7cd9ae39e947a5e11a36b4497cd4a"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "isort"
 | 
				
			||||||
 | 
					version = "5.12.0"
 | 
				
			||||||
 | 
					description = "A Python utility / library to sort Python imports."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.8.0"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "isort-5.12.0-py3-none-any.whl", hash = "sha256:f84c2818376e66cf843d497486ea8fed8700b340f308f076c6fb1229dff318b6"},
 | 
				
			||||||
 | 
					    {file = "isort-5.12.0.tar.gz", hash = "sha256:8bef7dde241278824a6d83f44a544709b065191b95b6e50894bdc722fcba0504"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					colors = ["colorama (>=0.4.3)"]
 | 
				
			||||||
 | 
					pipfile-deprecated-finder = ["pip-shims (>=0.5.2)", "pipreqs", "requirementslib"]
 | 
				
			||||||
 | 
					plugins = ["setuptools"]
 | 
				
			||||||
 | 
					requirements-deprecated-finder = ["pip-api", "pipreqs"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "jinja2"
 | 
				
			||||||
 | 
					version = "3.1.2"
 | 
				
			||||||
 | 
					description = "A very fast and expressive template engine."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "Jinja2-3.1.2-py3-none-any.whl", hash = "sha256:6088930bfe239f0e6710546ab9c19c9ef35e29792895fed6e6e31a023a182a61"},
 | 
				
			||||||
 | 
					    {file = "Jinja2-3.1.2.tar.gz", hash = "sha256:31351a702a408a9e7595a8fc6150fc3f43bb6bf7e319770cbc0db9df9437e852"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					MarkupSafe = ">=2.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					i18n = ["Babel (>=2.7)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "libvirt-python"
 | 
					name = "libvirt-python"
 | 
				
			||||||
version = "9.0.0"
 | 
					version = "9.0.0"
 | 
				
			||||||
@@ -22,6 +237,22 @@ files = [
 | 
				
			|||||||
    {file = "libvirt-python-9.0.0.tar.gz", hash = "sha256:49702d33fa8cbcae19fa727467a69f7ae2241b3091324085ca1cc752b2b414ce"},
 | 
					    {file = "libvirt-python-9.0.0.tar.gz", hash = "sha256:49702d33fa8cbcae19fa727467a69f7ae2241b3091324085ca1cc752b2b414ce"},
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "livereload"
 | 
				
			||||||
 | 
					version = "2.6.3"
 | 
				
			||||||
 | 
					description = "Python LiveReload is an awesome tool for web developers"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "livereload-2.6.3-py2.py3-none-any.whl", hash = "sha256:ad4ac6f53b2d62bb6ce1a5e6e96f1f00976a32348afedcb4b6d68df2a1d346e4"},
 | 
				
			||||||
 | 
					    {file = "livereload-2.6.3.tar.gz", hash = "sha256:776f2f865e59fde56490a56bcc6773b6917366bce0c267c60ee8aaf1a0959869"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					six = "*"
 | 
				
			||||||
 | 
					tornado = {version = "*", markers = "python_version > \"2.7\""}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[[package]]
 | 
					[[package]]
 | 
				
			||||||
name = "lxml"
 | 
					name = "lxml"
 | 
				
			||||||
version = "4.9.3"
 | 
					version = "4.9.3"
 | 
				
			||||||
@@ -130,7 +361,478 @@ html5 = ["html5lib"]
 | 
				
			|||||||
htmlsoup = ["BeautifulSoup4"]
 | 
					htmlsoup = ["BeautifulSoup4"]
 | 
				
			||||||
source = ["Cython (>=0.29.35)"]
 | 
					source = ["Cython (>=0.29.35)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "markupsafe"
 | 
				
			||||||
 | 
					version = "2.1.3"
 | 
				
			||||||
 | 
					description = "Safely add untrusted strings to HTML/XML markup."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_universal2.whl", hash = "sha256:cd0f502fe016460680cd20aaa5a76d241d6f35a1c3350c474bac1273803893fa"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e09031c87a1e51556fdcb46e5bd4f59dfb743061cf93c4d6831bf894f125eb57"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:68e78619a61ecf91e76aa3e6e8e33fc4894a2bebe93410754bd28fce0a8a4f9f"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:65c1a9bcdadc6c28eecee2c119465aebff8f7a584dd719facdd9e825ec61ab52"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:525808b8019e36eb524b8c68acdd63a37e75714eac50e988180b169d64480a00"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:962f82a3086483f5e5f64dbad880d31038b698494799b097bc59c2edf392fce6"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:aa7bd130efab1c280bed0f45501b7c8795f9fdbeb02e965371bbef3523627779"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c9c804664ebe8f83a211cace637506669e7890fec1b4195b505c214e50dd4eb7"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-win32.whl", hash = "sha256:10bbfe99883db80bdbaff2dcf681dfc6533a614f700da1287707e8a5d78a8431"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp310-cp310-win_amd64.whl", hash = "sha256:1577735524cdad32f9f694208aa75e422adba74f1baee7551620e43a3141f559"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_universal2.whl", hash = "sha256:ad9e82fb8f09ade1c3e1b996a6337afac2b8b9e365f926f5a61aacc71adc5b3c"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:3c0fae6c3be832a0a0473ac912810b2877c8cb9d76ca48de1ed31e1c68386575"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b076b6226fb84157e3f7c971a47ff3a679d837cf338547532ab866c57930dbee"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bfce63a9e7834b12b87c64d6b155fdd9b3b96191b6bd334bf37db7ff1fe457f2"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:338ae27d6b8745585f87218a3f23f1512dbf52c26c28e322dbe54bcede54ccb9"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:e4dd52d80b8c83fdce44e12478ad2e85c64ea965e75d66dbeafb0a3e77308fcc"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:df0be2b576a7abbf737b1575f048c23fb1d769f267ec4358296f31c2479db8f9"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:5bbe06f8eeafd38e5d0a4894ffec89378b6c6a625ff57e3028921f8ff59318ac"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-win32.whl", hash = "sha256:dd15ff04ffd7e05ffcb7fe79f1b98041b8ea30ae9234aed2a9168b5797c3effb"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp311-cp311-win_amd64.whl", hash = "sha256:134da1eca9ec0ae528110ccc9e48041e0828d79f24121a1a146161103c76e686"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_universal2.whl", hash = "sha256:f698de3fd0c4e6972b92290a45bd9b1536bffe8c6759c62471efaa8acb4c37bc"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:aa57bd9cf8ae831a362185ee444e15a93ecb2e344c8e52e4d721ea3ab6ef1823"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ffcc3f7c66b5f5b7931a5aa68fc9cecc51e685ef90282f4a82f0f5e9b704ad11"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:47d4f1c5f80fc62fdd7777d0d40a2e9dda0a05883ab11374334f6c4de38adffd"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:1f67c7038d560d92149c060157d623c542173016c4babc0c1913cca0564b9939"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:9aad3c1755095ce347e26488214ef77e0485a3c34a50c5a5e2471dff60b9dd9c"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_i686.whl", hash = "sha256:14ff806850827afd6b07a5f32bd917fb7f45b046ba40c57abdb636674a8b559c"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8f9293864fe09b8149f0cc42ce56e3f0e54de883a9de90cd427f191c346eb2e1"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-win32.whl", hash = "sha256:715d3562f79d540f251b99ebd6d8baa547118974341db04f5ad06d5ea3eb8007"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp312-cp312-win_amd64.whl", hash = "sha256:1b8dd8c3fd14349433c79fa8abeb573a55fc0fdd769133baac1f5e07abf54aeb"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:8e254ae696c88d98da6555f5ace2279cf7cd5b3f52be2b5cf97feafe883b58d2"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:cb0932dc158471523c9637e807d9bfb93e06a95cbf010f1a38b98623b929ef2b"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9402b03f1a1b4dc4c19845e5c749e3ab82d5078d16a2a4c2cd2df62d57bb0707"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:ca379055a47383d02a5400cb0d110cef0a776fc644cda797db0c5696cfd7e18e"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:b7ff0f54cb4ff66dd38bebd335a38e2c22c41a8ee45aa608efc890ac3e3931bc"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:c011a4149cfbcf9f03994ec2edffcb8b1dc2d2aede7ca243746df97a5d41ce48"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:56d9f2ecac662ca1611d183feb03a3fa4406469dafe241673d521dd5ae92a155"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp37-cp37m-win32.whl", hash = "sha256:8758846a7e80910096950b67071243da3e5a20ed2546e6392603c096778d48e0"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp37-cp37m-win_amd64.whl", hash = "sha256:787003c0ddb00500e49a10f2844fac87aa6ce977b90b0feaaf9de23c22508b24"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_universal2.whl", hash = "sha256:2ef12179d3a291be237280175b542c07a36e7f60718296278d8593d21ca937d4"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:2c1b19b3aaacc6e57b7e25710ff571c24d6c3613a45e905b1fde04d691b98ee0"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8afafd99945ead6e075b973fefa56379c5b5c53fd8937dad92c662da5d8fd5ee"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8c41976a29d078bb235fea9b2ecd3da465df42a562910f9022f1a03107bd02be"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:d080e0a5eb2529460b30190fcfcc4199bd7f827663f858a226a81bc27beaa97e"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_aarch64.whl", hash = "sha256:69c0f17e9f5a7afdf2cc9fb2d1ce6aabdb3bafb7f38017c0b77862bcec2bbad8"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:504b320cd4b7eff6f968eddf81127112db685e81f7e36e75f9f84f0df46041c3"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:42de32b22b6b804f42c5d98be4f7e5e977ecdd9ee9b660fda1a3edf03b11792d"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-win32.whl", hash = "sha256:ceb01949af7121f9fc39f7d27f91be8546f3fb112c608bc4029aef0bab86a2a5"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp38-cp38-win_amd64.whl", hash = "sha256:1b40069d487e7edb2676d3fbdb2b0829ffa2cd63a2ec26c4938b2d34391b4ecc"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_universal2.whl", hash = "sha256:8023faf4e01efadfa183e863fefde0046de576c6f14659e8782065bcece22198"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:6b2b56950d93e41f33b4223ead100ea0fe11f8e6ee5f641eb753ce4b77a7042b"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9dcdfd0eaf283af041973bff14a2e143b8bd64e069f4c383416ecd79a81aab58"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:05fb21170423db021895e1ea1e1f3ab3adb85d1c2333cbc2310f2a26bc77272e"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:282c2cb35b5b673bbcadb33a585408104df04f14b2d9b01d4c345a3b92861c2c"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_aarch64.whl", hash = "sha256:ab4a0df41e7c16a1392727727e7998a467472d0ad65f3ad5e6e765015df08636"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:7ef3cb2ebbf91e330e3bb937efada0edd9003683db6b57bb108c4001f37a02ea"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:0a4e4a1aff6c7ac4cd55792abf96c915634c2b97e3cc1c7129578aa68ebd754e"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-win32.whl", hash = "sha256:fec21693218efe39aa7f8599346e90c705afa52c5b31ae019b2e57e8f6542bb2"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3-cp39-cp39-win_amd64.whl", hash = "sha256:3fd4abcb888d15a94f32b75d8fd18ee162ca0c064f35b11134be77050296d6ba"},
 | 
				
			||||||
 | 
					    {file = "MarkupSafe-2.1.3.tar.gz", hash = "sha256:af598ed32d6ae86f1b747b82783958b1a4ab8f617b06fe68795c7f026abbdcad"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "packaging"
 | 
				
			||||||
 | 
					version = "23.2"
 | 
				
			||||||
 | 
					description = "Core utilities for Python packages"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "packaging-23.2-py3-none-any.whl", hash = "sha256:8c491190033a9af7e1d931d0b5dacc2ef47509b34dd0de67ed209b5203fc88c7"},
 | 
				
			||||||
 | 
					    {file = "packaging-23.2.tar.gz", hash = "sha256:048fb0e9405036518eaaf48a55953c750c11e1a1b68e0dd1a9d62ed0c092cfc5"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "pydantic"
 | 
				
			||||||
 | 
					version = "1.10.4"
 | 
				
			||||||
 | 
					description = "Data validation and settings management using python type hints"
 | 
				
			||||||
 | 
					category = "main"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:b5635de53e6686fe7a44b5cf25fcc419a0d5e5c1a1efe73d49d48fe7586db854"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:6dc1cc241440ed7ca9ab59d9929075445da6b7c94ced281b3dd4cfe6c8cff817"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:51bdeb10d2db0f288e71d49c9cefa609bca271720ecd0c58009bd7504a0c464c"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:78cec42b95dbb500a1f7120bdf95c401f6abb616bbe8785ef09887306792e66e"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp310-cp310-musllinux_1_1_i686.whl", hash = "sha256:8775d4ef5e7299a2f4699501077a0defdaac5b6c4321173bcb0f3c496fbadf85"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:572066051eeac73d23f95ba9a71349c42a3e05999d0ee1572b7860235b850cc6"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp310-cp310-win_amd64.whl", hash = "sha256:7feb6a2d401f4d6863050f58325b8d99c1e56f4512d98b11ac64ad1751dc647d"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:39f4a73e5342b25c2959529f07f026ef58147249f9b7431e1ba8414a36761f53"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:983e720704431a6573d626b00662eb78a07148c9115129f9b4351091ec95ecc3"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:75d52162fe6b2b55964fbb0af2ee58e99791a3138588c482572bb6087953113a"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:fdf8d759ef326962b4678d89e275ffc55b7ce59d917d9f72233762061fd04a2d"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp311-cp311-musllinux_1_1_i686.whl", hash = "sha256:05a81b006be15655b2a1bae5faa4280cf7c81d0e09fcb49b342ebf826abe5a72"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:d88c4c0e5c5dfd05092a4b271282ef0588e5f4aaf345778056fc5259ba098857"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp311-cp311-win_amd64.whl", hash = "sha256:6a05a9db1ef5be0fe63e988f9617ca2551013f55000289c671f71ec16f4985e3"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:887ca463c3bc47103c123bc06919c86720e80e1214aab79e9b779cda0ff92a00"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:fdf88ab63c3ee282c76d652fc86518aacb737ff35796023fae56a65ced1a5978"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp37-cp37m-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:a48f1953c4a1d9bd0b5167ac50da9a79f6072c63c4cef4cf2a3736994903583e"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp37-cp37m-musllinux_1_1_i686.whl", hash = "sha256:a9f2de23bec87ff306aef658384b02aa7c32389766af3c5dee9ce33e80222dfa"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:cd8702c5142afda03dc2b1ee6bc358b62b3735b2cce53fc77b31ca9f728e4bc8"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp37-cp37m-win_amd64.whl", hash = "sha256:6e7124d6855b2780611d9f5e1e145e86667eaa3bd9459192c8dc1a097f5e9903"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp38-cp38-macosx_10_9_x86_64.whl", hash = "sha256:0b53e1d41e97063d51a02821b80538053ee4608b9a181c1005441f1673c55423"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp38-cp38-macosx_11_0_arm64.whl", hash = "sha256:55b1625899acd33229c4352ce0ae54038529b412bd51c4915349b49ca575258f"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:301d626a59edbe5dfb48fcae245896379a450d04baeed50ef40d8199f2733b06"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp38-cp38-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:b6f9d649892a6f54a39ed56b8dfd5e08b5f3be5f893da430bed76975f3735d15"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp38-cp38-musllinux_1_1_i686.whl", hash = "sha256:d7b5a3821225f5c43496c324b0d6875fde910a1c2933d726a743ce328fbb2a8c"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:f2f7eb6273dd12472d7f218e1fef6f7c7c2f00ac2e1ecde4db8824c457300416"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp38-cp38-win_amd64.whl", hash = "sha256:4b05697738e7d2040696b0a66d9f0a10bec0efa1883ca75ee9e55baf511909d6"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:a9a6747cac06c2beb466064dda999a13176b23535e4c496c9d48e6406f92d42d"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp39-cp39-macosx_11_0_arm64.whl", hash = "sha256:eb992a1ef739cc7b543576337bebfc62c0e6567434e522e97291b251a41dad7f"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:990406d226dea0e8f25f643b370224771878142155b879784ce89f633541a024"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp39-cp39-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:2e82a6d37a95e0b1b42b82ab340ada3963aea1317fd7f888bb6b9dfbf4fff57c"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp39-cp39-musllinux_1_1_i686.whl", hash = "sha256:9193d4f4ee8feca58bc56c8306bcb820f5c7905fd919e0750acdeeeef0615b28"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:2b3ce5f16deb45c472dde1a0ee05619298c864a20cded09c4edd820e1454129f"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-cp39-cp39-win_amd64.whl", hash = "sha256:9cbdc268a62d9a98c56e2452d6c41c0263d64a2009aac69246486f01b4f594c4"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4-py3-none-any.whl", hash = "sha256:4948f264678c703f3877d1c8877c4e3b2e12e549c57795107f08cf70c6ec7774"},
 | 
				
			||||||
 | 
					    {file = "pydantic-1.10.4.tar.gz", hash = "sha256:b9a3859f24eb4e097502a3be1fb4b2abb79b6103dd9e2e0edb70613a4459a648"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					typing-extensions = ">=4.2.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					dotenv = ["python-dotenv (>=0.10.4)"]
 | 
				
			||||||
 | 
					email = ["email-validator (>=1.0.3)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "pygments"
 | 
				
			||||||
 | 
					version = "2.16.1"
 | 
				
			||||||
 | 
					description = "Pygments is a syntax highlighting package written in Python."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "Pygments-2.16.1-py3-none-any.whl", hash = "sha256:13fc09fa63bc8d8671a6d247e1eb303c4b343eaee81d861f3404db2935653692"},
 | 
				
			||||||
 | 
					    {file = "Pygments-2.16.1.tar.gz", hash = "sha256:1daff0494820c69bc8941e407aa20f577374ee88364ee10a98fdbe0aece96e29"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					plugins = ["importlib-metadata"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "requests"
 | 
				
			||||||
 | 
					version = "2.31.0"
 | 
				
			||||||
 | 
					description = "Python HTTP for Humans."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "requests-2.31.0-py3-none-any.whl", hash = "sha256:58cd2187c01e70e6e26505bca751777aa9f2ee0b7f4300988b709f44e013003f"},
 | 
				
			||||||
 | 
					    {file = "requests-2.31.0.tar.gz", hash = "sha256:942c5a758f98d790eaed1a29cb6eefc7ffb0d1cf7af05c3d2791656dbd6ad1e1"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					certifi = ">=2017.4.17"
 | 
				
			||||||
 | 
					charset-normalizer = ">=2,<4"
 | 
				
			||||||
 | 
					idna = ">=2.5,<4"
 | 
				
			||||||
 | 
					urllib3 = ">=1.21.1,<3"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					socks = ["PySocks (>=1.5.6,!=1.5.7)"]
 | 
				
			||||||
 | 
					use-chardet-on-py3 = ["chardet (>=3.0.2,<6)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "ruff"
 | 
				
			||||||
 | 
					version = "0.1.3"
 | 
				
			||||||
 | 
					description = "An extremely fast Python linter, written in Rust."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-macosx_10_7_x86_64.whl", hash = "sha256:b46d43d51f7061652eeadb426a9e3caa1e0002470229ab2fc19de8a7b0766901"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-macosx_10_9_x86_64.macosx_11_0_arm64.macosx_10_9_universal2.whl", hash = "sha256:b8afeb9abd26b4029c72adc9921b8363374f4e7edb78385ffaa80278313a15f9"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ca3cf365bf32e9ba7e6db3f48a4d3e2c446cd19ebee04f05338bc3910114528b"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:4874c165f96c14a00590dcc727a04dca0cfd110334c24b039458c06cf78a672e"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:eec2dd31eed114e48ea42dbffc443e9b7221976554a504767ceaee3dd38edeb8"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-manylinux_2_17_ppc64.manylinux2014_ppc64.whl", hash = "sha256:dc3ec4edb3b73f21b4aa51337e16674c752f1d76a4a543af56d7d04e97769613"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2e3de9ed2e39160800281848ff4670e1698037ca039bda7b9274f849258d26ce"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:1c595193881922cc0556a90f3af99b1c5681f0c552e7a2a189956141d8666fe8"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0f75e670d529aa2288cd00fc0e9b9287603d95e1536d7a7e0cafe00f75e0dd9d"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:76dd49f6cd945d82d9d4a9a6622c54a994689d8d7b22fa1322983389b4892e20"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:918b454bc4f8874a616f0d725590277c42949431ceb303950e87fef7a7d94cb3"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:d8859605e729cd5e53aa38275568dbbdb4fe882d2ea2714c5453b678dca83784"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:0b6c55f5ef8d9dd05b230bb6ab80bc4381ecb60ae56db0330f660ea240cb0d4a"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-win32.whl", hash = "sha256:3e7afcbdcfbe3399c34e0f6370c30f6e529193c731b885316c5a09c9e4317eef"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-win_amd64.whl", hash = "sha256:7a18df6638cec4a5bd75350639b2bb2a2366e01222825562c7346674bdceb7ea"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3-py3-none-win_arm64.whl", hash = "sha256:12fd53696c83a194a2db7f9a46337ce06445fb9aa7d25ea6f293cf75b21aca9f"},
 | 
				
			||||||
 | 
					    {file = "ruff-0.1.3.tar.gz", hash = "sha256:3ba6145369a151401d5db79f0a47d50e470384d0d89d0d6f7fab0b589ad07c34"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "setuptools"
 | 
				
			||||||
 | 
					version = "68.2.2"
 | 
				
			||||||
 | 
					description = "Easily download, build, install, upgrade, and uninstall Python packages"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.8"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "setuptools-68.2.2-py3-none-any.whl", hash = "sha256:b454a35605876da60632df1a60f736524eb73cc47bbc9f3f1ef1b644de74fd2a"},
 | 
				
			||||||
 | 
					    {file = "setuptools-68.2.2.tar.gz", hash = "sha256:4ac1475276d2f1c48684874089fefcd83bd7162ddaafb81fac866ba0db282a87"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					docs = ["furo", "jaraco.packaging (>=9.3)", "jaraco.tidelift (>=1.4)", "pygments-github-lexers (==0.0.5)", "rst.linker (>=1.9)", "sphinx (>=3.5)", "sphinx-favicon", "sphinx-hoverxref (<2)", "sphinx-inline-tabs", "sphinx-lint", "sphinx-notfound-page (>=1,<2)", "sphinx-reredirects", "sphinxcontrib-towncrier"]
 | 
				
			||||||
 | 
					testing = ["build[virtualenv]", "filelock (>=3.4.0)", "flake8-2020", "ini2toml[lite] (>=0.9)", "jaraco.develop (>=7.21)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "pip (>=19.1)", "pytest (>=6)", "pytest-black (>=0.3.7)", "pytest-checkdocs (>=2.4)", "pytest-cov", "pytest-enabler (>=2.2)", "pytest-mypy (>=0.9.1)", "pytest-perf", "pytest-ruff", "pytest-timeout", "pytest-xdist", "tomli-w (>=1.0.0)", "virtualenv (>=13.0.0)", "wheel"]
 | 
				
			||||||
 | 
					testing-integration = ["build[virtualenv] (>=1.0.3)", "filelock (>=3.4.0)", "jaraco.envs (>=2.2)", "jaraco.path (>=3.2.0)", "packaging (>=23.1)", "pytest", "pytest-enabler", "pytest-xdist", "tomli", "virtualenv (>=13.0.0)", "wheel"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "six"
 | 
				
			||||||
 | 
					version = "1.16.0"
 | 
				
			||||||
 | 
					description = "Python 2 and 3 compatibility utilities"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "six-1.16.0-py2.py3-none-any.whl", hash = "sha256:8abb2f1d86890a2dfb989f9a77cfcfd3e47c2a354b01111771326f8aa26e0254"},
 | 
				
			||||||
 | 
					    {file = "six-1.16.0.tar.gz", hash = "sha256:1e61c37477a1626458e36f7b1d82aa5c9b094fa4802892072e49de9c60c4c926"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "snowballstemmer"
 | 
				
			||||||
 | 
					version = "2.2.0"
 | 
				
			||||||
 | 
					description = "This package provides 29 stemmers for 28 languages generated from Snowball algorithms."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "snowballstemmer-2.2.0-py2.py3-none-any.whl", hash = "sha256:c8e1716e83cc398ae16824e5572ae04e0d9fc2c6b985fb0f900f5f0c96ecba1a"},
 | 
				
			||||||
 | 
					    {file = "snowballstemmer-2.2.0.tar.gz", hash = "sha256:09b16deb8547d3412ad7b590689584cd0fe25ec8db3be37788be3810cbf19cb1"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinx"
 | 
				
			||||||
 | 
					version = "7.2.6"
 | 
				
			||||||
 | 
					description = "Python documentation generator"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.9"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "sphinx-7.2.6-py3-none-any.whl", hash = "sha256:1e09160a40b956dc623c910118fa636da93bd3ca0b9876a7b3df90f07d691560"},
 | 
				
			||||||
 | 
					    {file = "sphinx-7.2.6.tar.gz", hash = "sha256:9a5160e1ea90688d5963ba09a2dcd8bdd526620edbb65c328728f1b2228d5ab5"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					alabaster = ">=0.7,<0.8"
 | 
				
			||||||
 | 
					babel = ">=2.9"
 | 
				
			||||||
 | 
					colorama = {version = ">=0.4.5", markers = "sys_platform == \"win32\""}
 | 
				
			||||||
 | 
					docutils = ">=0.18.1,<0.21"
 | 
				
			||||||
 | 
					imagesize = ">=1.3"
 | 
				
			||||||
 | 
					Jinja2 = ">=3.0"
 | 
				
			||||||
 | 
					packaging = ">=21.0"
 | 
				
			||||||
 | 
					Pygments = ">=2.14"
 | 
				
			||||||
 | 
					requests = ">=2.25.0"
 | 
				
			||||||
 | 
					snowballstemmer = ">=2.0"
 | 
				
			||||||
 | 
					sphinxcontrib-applehelp = "*"
 | 
				
			||||||
 | 
					sphinxcontrib-devhelp = "*"
 | 
				
			||||||
 | 
					sphinxcontrib-htmlhelp = ">=2.0.0"
 | 
				
			||||||
 | 
					sphinxcontrib-jsmath = "*"
 | 
				
			||||||
 | 
					sphinxcontrib-qthelp = "*"
 | 
				
			||||||
 | 
					sphinxcontrib-serializinghtml = ">=1.1.9"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					docs = ["sphinxcontrib-websupport"]
 | 
				
			||||||
 | 
					lint = ["docutils-stubs", "flake8 (>=3.5.0)", "flake8-simplify", "isort", "mypy (>=0.990)", "ruff", "sphinx-lint", "types-requests"]
 | 
				
			||||||
 | 
					test = ["cython (>=3.0)", "filelock", "html5lib", "pytest (>=4.6)", "setuptools (>=67.0)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinx-autobuild"
 | 
				
			||||||
 | 
					version = "2021.3.14"
 | 
				
			||||||
 | 
					description = "Rebuild Sphinx documentation on changes, with live-reload in the browser."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.6"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "sphinx-autobuild-2021.3.14.tar.gz", hash = "sha256:de1ca3b66e271d2b5b5140c35034c89e47f263f2cd5db302c9217065f7443f05"},
 | 
				
			||||||
 | 
					    {file = "sphinx_autobuild-2021.3.14-py3-none-any.whl", hash = "sha256:8fe8cbfdb75db04475232f05187c776f46f6e9e04cacf1e49ce81bdac649ccac"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					colorama = "*"
 | 
				
			||||||
 | 
					livereload = "*"
 | 
				
			||||||
 | 
					sphinx = "*"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					test = ["pytest", "pytest-cov"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinx-multiversion"
 | 
				
			||||||
 | 
					version = "0.2.4"
 | 
				
			||||||
 | 
					description = "Add support for multiple versions to sphinx"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = "*"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "sphinx-multiversion-0.2.4.tar.gz", hash = "sha256:5cd1ca9ecb5eed63cb8d6ce5e9c438ca13af4fa98e7eb6f376be541dd4990bcb"},
 | 
				
			||||||
 | 
					    {file = "sphinx_multiversion-0.2.4-py3-none-any.whl", hash = "sha256:dec29f2a5890ad68157a790112edc0eb63140e70f9df0a363743c6258fbeb478"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					sphinx = ">=2.1"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-applehelp"
 | 
				
			||||||
 | 
					version = "1.0.7"
 | 
				
			||||||
 | 
					description = "sphinxcontrib-applehelp is a Sphinx extension which outputs Apple help books"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.9"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_applehelp-1.0.7-py3-none-any.whl", hash = "sha256:094c4d56209d1734e7d252f6e0b3ccc090bd52ee56807a5d9315b19c122ab15d"},
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_applehelp-1.0.7.tar.gz", hash = "sha256:39fdc8d762d33b01a7d8f026a3b7d71563ea3b72787d5f00ad8465bd9d6dfbfa"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					Sphinx = ">=5"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					lint = ["docutils-stubs", "flake8", "mypy"]
 | 
				
			||||||
 | 
					test = ["pytest"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-devhelp"
 | 
				
			||||||
 | 
					version = "1.0.5"
 | 
				
			||||||
 | 
					description = "sphinxcontrib-devhelp is a sphinx extension which outputs Devhelp documents"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.9"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_devhelp-1.0.5-py3-none-any.whl", hash = "sha256:fe8009aed765188f08fcaadbb3ea0d90ce8ae2d76710b7e29ea7d047177dae2f"},
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_devhelp-1.0.5.tar.gz", hash = "sha256:63b41e0d38207ca40ebbeabcf4d8e51f76c03e78cd61abe118cf4435c73d4212"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					Sphinx = ">=5"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					lint = ["docutils-stubs", "flake8", "mypy"]
 | 
				
			||||||
 | 
					test = ["pytest"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-htmlhelp"
 | 
				
			||||||
 | 
					version = "2.0.4"
 | 
				
			||||||
 | 
					description = "sphinxcontrib-htmlhelp is a sphinx extension which renders HTML help files"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.9"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_htmlhelp-2.0.4-py3-none-any.whl", hash = "sha256:8001661c077a73c29beaf4a79968d0726103c5605e27db92b9ebed8bab1359e9"},
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_htmlhelp-2.0.4.tar.gz", hash = "sha256:6c26a118a05b76000738429b724a0568dbde5b72391a688577da08f11891092a"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					Sphinx = ">=5"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					lint = ["docutils-stubs", "flake8", "mypy"]
 | 
				
			||||||
 | 
					test = ["html5lib", "pytest"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-jsmath"
 | 
				
			||||||
 | 
					version = "1.0.1"
 | 
				
			||||||
 | 
					description = "A sphinx extension which renders display math in HTML via JavaScript"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.5"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib-jsmath-1.0.1.tar.gz", hash = "sha256:a9925e4a4587247ed2191a22df5f6970656cb8ca2bd6284309578f2153e0c4b8"},
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_jsmath-1.0.1-py2.py3-none-any.whl", hash = "sha256:2ec2eaebfb78f3f2078e73666b1415417a116cc848b72e5172e596c871103178"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					test = ["flake8", "mypy", "pytest"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-qthelp"
 | 
				
			||||||
 | 
					version = "1.0.6"
 | 
				
			||||||
 | 
					description = "sphinxcontrib-qthelp is a sphinx extension which outputs QtHelp documents"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.9"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_qthelp-1.0.6-py3-none-any.whl", hash = "sha256:bf76886ee7470b934e363da7a954ea2825650013d367728588732c7350f49ea4"},
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_qthelp-1.0.6.tar.gz", hash = "sha256:62b9d1a186ab7f5ee3356d906f648cacb7a6bdb94d201ee7adf26db55092982d"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					Sphinx = ">=5"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					lint = ["docutils-stubs", "flake8", "mypy"]
 | 
				
			||||||
 | 
					test = ["pytest"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "sphinxcontrib-serializinghtml"
 | 
				
			||||||
 | 
					version = "1.1.9"
 | 
				
			||||||
 | 
					description = "sphinxcontrib-serializinghtml is a sphinx extension which outputs \"serialized\" HTML files (json and pickle)"
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.9"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_serializinghtml-1.1.9-py3-none-any.whl", hash = "sha256:9b36e503703ff04f20e9675771df105e58aa029cfcbc23b8ed716019b7416ae1"},
 | 
				
			||||||
 | 
					    {file = "sphinxcontrib_serializinghtml-1.1.9.tar.gz", hash = "sha256:0c64ff898339e1fac29abd2bf5f11078f3ec413cfe9c046d3120d7ca65530b54"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.dependencies]
 | 
				
			||||||
 | 
					Sphinx = ">=5"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					lint = ["docutils-stubs", "flake8", "mypy"]
 | 
				
			||||||
 | 
					test = ["pytest"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "tornado"
 | 
				
			||||||
 | 
					version = "6.3.3"
 | 
				
			||||||
 | 
					description = "Tornado is a Python web framework and asynchronous networking library, originally developed at FriendFeed."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">= 3.8"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-macosx_10_9_universal2.whl", hash = "sha256:502fba735c84450974fec147340016ad928d29f1e91f49be168c0a4c18181e1d"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-macosx_10_9_x86_64.whl", hash = "sha256:805d507b1f588320c26f7f097108eb4023bbaa984d63176d1652e184ba24270a"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1bd19ca6c16882e4d37368e0152f99c099bad93e0950ce55e71daed74045908f"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-manylinux_2_5_i686.manylinux1_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7ac51f42808cca9b3613f51ffe2a965c8525cb1b00b7b2d56828b8045354f76a"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-manylinux_2_5_x86_64.manylinux1_x86_64.manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:71a8db65160a3c55d61839b7302a9a400074c9c753040455494e2af74e2501f2"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_aarch64.whl", hash = "sha256:ceb917a50cd35882b57600709dd5421a418c29ddc852da8bcdab1f0db33406b0"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_i686.whl", hash = "sha256:7d01abc57ea0dbb51ddfed477dfe22719d376119844e33c661d873bf9c0e4a16"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-musllinux_1_1_x86_64.whl", hash = "sha256:9dc4444c0defcd3929d5c1eb5706cbe1b116e762ff3e0deca8b715d14bf6ec17"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-win32.whl", hash = "sha256:65ceca9500383fbdf33a98c0087cb975b2ef3bfb874cb35b8de8740cf7f41bd3"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3-cp38-abi3-win_amd64.whl", hash = "sha256:22d3c2fa10b5793da13c807e6fc38ff49a4f6e1e3868b0a6f4164768bb8e20f5"},
 | 
				
			||||||
 | 
					    {file = "tornado-6.3.3.tar.gz", hash = "sha256:e7d8db41c0181c80d76c982aacc442c0783a2c54d6400fe028954201a2e032fe"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "typing-extensions"
 | 
				
			||||||
 | 
					version = "4.8.0"
 | 
				
			||||||
 | 
					description = "Backported and Experimental Type Hints for Python 3.8+"
 | 
				
			||||||
 | 
					category = "main"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.8"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "typing_extensions-4.8.0-py3-none-any.whl", hash = "sha256:8f92fc8806f9a6b641eaa5318da32b44d401efaac0f6678c9bc448ba3605faa0"},
 | 
				
			||||||
 | 
					    {file = "typing_extensions-4.8.0.tar.gz", hash = "sha256:df8e4339e9cb77357558cbdbceca33c303714cf861d1eef15e1070055ae8b7ef"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[[package]]
 | 
				
			||||||
 | 
					name = "urllib3"
 | 
				
			||||||
 | 
					version = "2.0.7"
 | 
				
			||||||
 | 
					description = "HTTP library with thread-safe connection pooling, file post, and more."
 | 
				
			||||||
 | 
					category = "dev"
 | 
				
			||||||
 | 
					optional = false
 | 
				
			||||||
 | 
					python-versions = ">=3.7"
 | 
				
			||||||
 | 
					files = [
 | 
				
			||||||
 | 
					    {file = "urllib3-2.0.7-py3-none-any.whl", hash = "sha256:fdb6d215c776278489906c2f8916e6e7d4f5a9b602ccbcfdf7f016fc8da0596e"},
 | 
				
			||||||
 | 
					    {file = "urllib3-2.0.7.tar.gz", hash = "sha256:c97dfde1f7bd43a71c8d2a58e369e9b2bf692d1334ea9f9cae55add7d0dd0f84"},
 | 
				
			||||||
 | 
					]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[package.extras]
 | 
				
			||||||
 | 
					brotli = ["brotli (>=1.0.9)", "brotlicffi (>=0.8.0)"]
 | 
				
			||||||
 | 
					secure = ["certifi", "cryptography (>=1.9)", "idna (>=2.0.0)", "pyopenssl (>=17.1.0)", "urllib3-secure-extra"]
 | 
				
			||||||
 | 
					socks = ["pysocks (>=1.5.6,!=1.5.7,<2.0)"]
 | 
				
			||||||
 | 
					zstd = ["zstandard (>=0.18.0)"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[metadata]
 | 
					[metadata]
 | 
				
			||||||
lock-version = "2.0"
 | 
					lock-version = "2.0"
 | 
				
			||||||
python-versions = "^3.11"
 | 
					python-versions = "^3.11"
 | 
				
			||||||
content-hash = "8e62a9e51f66c5a3a124d0e631ca68803f2c8d933a75faf2783dc4ddf118e7ab"
 | 
					content-hash = "413ca8b2e0d37bf9e2835dd9050a3cc98e4a37186c78b780a65d62d05adce8c1"
 | 
				
			||||||
 
 | 
				
			|||||||
@@ -1,7 +1,7 @@
 | 
				
			|||||||
[tool.poetry]
 | 
					[tool.poetry]
 | 
				
			||||||
name = "computelib"
 | 
					name = "compute"
 | 
				
			||||||
version = "0.1.0"
 | 
					version = "0.1.0"
 | 
				
			||||||
description = "Compute Node Agent library"
 | 
					description = "Library built on top of libvirt for Compute Service"
 | 
				
			||||||
authors = ["ge <ge@nixhacks.net>"]
 | 
					authors = ["ge <ge@nixhacks.net>"]
 | 
				
			||||||
readme = "README.md"
 | 
					readme = "README.md"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@@ -9,22 +9,46 @@ readme = "README.md"
 | 
				
			|||||||
python = "^3.11"
 | 
					python = "^3.11"
 | 
				
			||||||
libvirt-python = "9.0.0"
 | 
					libvirt-python = "9.0.0"
 | 
				
			||||||
lxml = "^4.9.2"
 | 
					lxml = "^4.9.2"
 | 
				
			||||||
docopt = "^0.6.2"
 | 
					pydantic = "1.10.4"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[tool.poetry.scripts]
 | 
					[tool.poetry.scripts]
 | 
				
			||||||
na-vmctl = "computelib.cli.vmctl:cli"
 | 
					compute = "compute.cli.control:cli"
 | 
				
			||||||
na-vmexec = "computelib.cli.vmexec:cli"
 | 
					
 | 
				
			||||||
 | 
					[tool.poetry.group.dev.dependencies]
 | 
				
			||||||
 | 
					ruff = "^0.1.3"
 | 
				
			||||||
 | 
					isort = "^5.12.0"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[tool.poetry.group.docs.dependencies]
 | 
				
			||||||
 | 
					sphinx = "^7.2.6"
 | 
				
			||||||
 | 
					sphinx-autobuild = "^2021.3.14"
 | 
				
			||||||
 | 
					sphinx-multiversion = "^0.2.4"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[build-system]
 | 
					[build-system]
 | 
				
			||||||
requires = ["poetry-core"]
 | 
					requires = ["poetry-core"]
 | 
				
			||||||
build-backend = "poetry.core.masonry.api"
 | 
					build-backend = "poetry.core.masonry.api"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
[tool.pylint."MESSAGES CONTROL"]
 | 
					[tool.ruff]
 | 
				
			||||||
disable = [
 | 
					line-length = 79
 | 
				
			||||||
    "invalid-name",
 | 
					indent-width = 4
 | 
				
			||||||
    "missing-module-docstring",
 | 
					target-version = "py311"
 | 
				
			||||||
    "missing-class-docstring",
 | 
					
 | 
				
			||||||
    "missing-function-docstring",
 | 
					[tool.ruff.lint]
 | 
				
			||||||
    "import-error",
 | 
					select = ["ALL"]
 | 
				
			||||||
    "too-many-arguments",
 | 
					ignore = [
 | 
				
			||||||
 | 
					    "Q000", "Q003", "D211", "D212", "ANN101", "ISC001", "COM812",
 | 
				
			||||||
 | 
					    "D203", "ANN204", "T201",
 | 
				
			||||||
 | 
					    "EM102", "TRY003", # maybe not ignore?
 | 
				
			||||||
 | 
					    "TD003", "TD006", "FIX002",  # todo strings linting
 | 
				
			||||||
]
 | 
					]
 | 
				
			||||||
 | 
					exclude = ["__init__.py"]
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[tool.ruff.lint.flake8-annotations]
 | 
				
			||||||
 | 
					mypy-init-return = true
 | 
				
			||||||
 | 
					allow-star-arg-any = true
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[tool.ruff.format]
 | 
				
			||||||
 | 
					quote-style = "single"
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					[tool.ruff.isort]
 | 
				
			||||||
 | 
					lines-after-imports = 2
 | 
				
			||||||
 
 | 
				
			|||||||
		Reference in New Issue
	
	Block a user