various improvements

This commit is contained in:
ge 2023-11-06 12:52:19 +03:00
parent 2870708365
commit ffa7605201
46 changed files with 2698 additions and 1282 deletions

7
.gitignore vendored
View File

@ -1,8 +1,7 @@
dist/
docs/build/
.ruff_cache/
__pycache__/
*.pyc
*~
dom*
na
dist/
P@ssw0rd
*.todo

View File

@ -1,17 +1,33 @@
SRC = computelib/
SRC = compute/
DIST = dist/
DOCS_SRC = docs/source/
DOCS_BUILD = docs/build/
.PHONY: docs
all: build
build:
build: format lint
poetry build
clean:
[ -d dist/ ] && rm -rf dist/ || true
find . -type d -name __pycache__ -exec rm -rf {} \; > /dev/null 2>&1 || true
format:
isort --lai 2 $(SRC)
autopep8 -riva --experimental --ignore e255 $(SRC)
poetry run isort --lai 2 $(SRC)
poetry run ruff format $(SRC)
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:~

View File

@ -1,8 +1,8 @@
# Compute Node Agent library
# Compute Service
В этом репозитории развивается базовая библиотека для взаимодействия с libvirt и выполнения операций с виртуальными машинами. Фокус на QEMU/KVM.
# Зависимости (версии из APT репозитория Debian 12):
## Зависимости (версии из репозитория Debian 12):
- `python3-lxml` 4.9.2
- `python3-docopt` 0.6.2
@ -10,71 +10,40 @@
Минимальная поддерживаемая версия Python — `3.11`, потому, что можем.
# Утилиты
- `na-vmctl` virsh на минималках. Выполняет базовые операции с VM, установку и миграцию и т.п.
- `na-vmexec`. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна.
- `na-volctl`. Предполагается здесь оставить всю работу с дисками. Не реализована.
# API
Кодовая база растёт, необходимо автоматически генерировать документацию, в README её больше не будет.
## API
В структуре проекта сейчас бардак, многое будет переосмыслено и переделано позже. Основная цель на текущем этапе — получить минимально работающий код, с помощью которого возможно выполнить установку виртуальной машины и как-то управлять ею.
Есть набор классов, предоставляющих собой интерфейсы для взаимодействия с виртуальными машинами, стораджами, дисками и т.п. Датаклассы описывают сущности и имеют метод `to_xml()` для получения XML конфига для `libvirt`. Смысл датакласса в том, чтобы иметь один объект, содержащий в себе нормальные легкочитаемые аттрибуты и XML описание сущности одновременно.
Есть набор классов, предоставляющих собой интерфейсы для взаимодействия с виртуальными машинами, стораджами, дисками и т.п. Датаклассы описывают сущности и имеют метод `to_xml()` для получения XML конфига для `libvirt`. Смысл использования датаклассов в том, чтобы иметь один объект, содержащий в себе нормальные легкочитаемые аттрибуты и XML описание сущности одновременно.
# TODO
## ROADMAP
- [x] Установка ВМ (всратый вариант)
- [x] Конструктор XML (базовый)
- [x] Автоматический выбор модели процессора
- [x] Метод создания дисков
- [x] Дефайн, запуск и автостарт ВМ
- [x] Работа со StoragePool
- [x] Создание блочных устройств
- [x] Подключение/отключение устройств
- [x] Метод install()
- [ ] Выбор между SeaBIOS/UEFI
- [ ] Выбор модели процессора
- [ ] Установка ВМ (нормальный вариант)
- [x] Управление дисками
- [x] Локальные qcow2
- [ ] ZVOL
- [ ] Сетевые диски
- [ ] Живой ресайз файловой системы (?)
- [x] Удаление ВМ
- [x] Изменение CPU
- [ ] Полноценный hotplug
- [x] Изменение RAM
- [ ] Полноценный hotplug
- [ ] Миграция ВМ между нодами
- [x] Работа с qemu-ga
- [x] Управление питанием
- [x] Вкл/выкл автостарт ВМ
- [ ] Установка инстансов
- [ ] Установка с использованием эталонного образа ОС
- [ ] Установка с пустым диском и загрузкой с ISO
- [ ] Установка с использованием готового волюма
- [x] Базовое управление питанием
- [ ] Остановка и возобновление инстансов
- [ ] Изменение числа vCPU на горячую
- [ ] Изменение топологии процессора
- [ ] Выбор типа эмуляции процессора, вендора, модели и инструкций
- [ ] Изменение памяти на горячую
- [ ] Ресайз дисков на горячую
- [ ] Выбор между BIOS и UEFI
- [ ] Редактирование параметров загрузки (boot menu, etc)
- [x] Горячее подключение устройств
- [ ] Горячее отключение устройств
- [ ] GPU
- [ ] Поддержка инстансов с разной гарантированной долей CPU
- [x] Базовое управление QEMU Guest Agent
- [ ] Проверка доступности и возможностей QEMU Guest Agent
- [ ] Статистика потребления ресурсов
- [ ] Получение инфомрации из/о ВМ
- [ ] 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 на тот же диск ничего не даст.
- Сетевой диск должен быть зарезервирован средствами распределённой ФС
- [ ] Управление SSH-ключами
- [ ] Изменение пароля root
- [ ] LXC
- [ ] Работа с дисками QCOW2,3
- [ ] ZVOL
- [ ] Сетевые диски
- [ ] Создание Storage Pool на основе TOML/YAML описания
- [ ] Удаление Storage Pool
- [ ] Снапшоты

7
compute/__init__.py Normal file
View 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
View 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
View 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
View 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
View 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")

View File

@ -0,0 +1,3 @@
from .guest_agent import GuestAgent
from .instance import Instance, InstanceConfig
from .schemas import InstanceSchema

View 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

View 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
View 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
View 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()]

View File

@ -0,0 +1,2 @@
from .pool import StoragePool
from .volume import DiskConfig, Volume, VolumeConfig

114
compute/storage/pool.py Normal file
View 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
View 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()

View File

View 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

View 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
View 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])

View File

@ -1,5 +0,0 @@
from .config import ConfigLoader
from .exceptions import *
from .session import LibvirtSession
from .vm import *
from .volume import *

View File

@ -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()

View File

@ -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()

View File

@ -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()

View File

@ -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."""

View File

@ -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()

View File

@ -1 +0,0 @@
from . import mac, xml

View File

@ -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))

View File

@ -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)

View File

@ -1,3 +0,0 @@
from .guest_agent import GuestAgent
from .installer import VirtualMachineInstaller
from .virtual_machine import VirtualMachine

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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

View File

@ -1,2 +0,0 @@
from .storage_pool import StoragePool
from .volume import DiskInfo, Volume, VolumeInfo

View File

@ -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()]

View File

@ -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()

View File

@ -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
View 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
View 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

View 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
View 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
View File

@ -0,0 +1,15 @@
Compute Service
===============
Документация библиотеки для управления Compute-инстансами.
.. toctree::
:maxdepth: 2
:caption: Contents:
Индексы и таблицы
-----------------
* :ref:`genindex`
* :ref:`modindex`
* :ref:`search`

716
poetry.lock generated
View File

@ -1,16 +1,231 @@
# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
[[package]]
name = "docopt"
version = "0.6.2"
description = "Pythonic argument parser, that will make you smile"
category = "main"
name = "alabaster"
version = "0.7.13"
description = "A configurable sidebar-enabled Sphinx theme"
category = "dev"
optional = false
python-versions = "*"
python-versions = ">=3.6"
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]]
name = "libvirt-python"
version = "9.0.0"
@ -22,6 +237,22 @@ files = [
{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]]
name = "lxml"
version = "4.9.3"
@ -130,7 +361,478 @@ html5 = ["html5lib"]
htmlsoup = ["BeautifulSoup4"]
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]
lock-version = "2.0"
python-versions = "^3.11"
content-hash = "8e62a9e51f66c5a3a124d0e631ca68803f2c8d933a75faf2783dc4ddf118e7ab"
content-hash = "413ca8b2e0d37bf9e2835dd9050a3cc98e4a37186c78b780a65d62d05adce8c1"

View File

@ -1,7 +1,7 @@
[tool.poetry]
name = "computelib"
name = "compute"
version = "0.1.0"
description = "Compute Node Agent library"
description = "Library built on top of libvirt for Compute Service"
authors = ["ge <ge@nixhacks.net>"]
readme = "README.md"
@ -9,22 +9,46 @@ readme = "README.md"
python = "^3.11"
libvirt-python = "9.0.0"
lxml = "^4.9.2"
docopt = "^0.6.2"
pydantic = "1.10.4"
[tool.poetry.scripts]
na-vmctl = "computelib.cli.vmctl:cli"
na-vmexec = "computelib.cli.vmexec:cli"
compute = "compute.cli.control: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]
requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api"
[tool.pylint."MESSAGES CONTROL"]
disable = [
"invalid-name",
"missing-module-docstring",
"missing-class-docstring",
"missing-function-docstring",
"import-error",
"too-many-arguments",
[tool.ruff]
line-length = 79
indent-width = 4
target-version = "py311"
[tool.ruff.lint]
select = ["ALL"]
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