From ffa76052010a8fec62b32e9b352004ee143599e3 Mon Sep 17 00:00:00 2001 From: ge Date: Mon, 6 Nov 2023 12:52:19 +0300 Subject: [PATCH] various improvements --- .gitignore | 7 +- Makefile | 34 +- README.md | 97 +-- compute/__init__.py | 7 + compute/__main__.py | 6 + .../cli/volctl.py => compute/cli/__init__.py | 0 compute/cli/_create.py | 26 + compute/cli/control.py | 319 ++++++++ compute/exceptions.py | 49 ++ compute/instance/__init__.py | 3 + compute/instance/guest_agent.py | 197 +++++ compute/instance/instance.py | 551 ++++++++++++++ compute/instance/schemas.py | 126 +++ compute/session.py | 156 ++++ compute/storage/__init__.py | 2 + compute/storage/pool.py | 114 +++ compute/storage/volume.py | 124 +++ compute/utils/__init__.py | 0 compute/utils/config_loader.py | 41 + compute/utils/identifiers.py | 18 + compute/utils/units.py | 39 + computelib/__init__.py | 5 - computelib/cli/vmctl.py | 108 --- computelib/cli/vmexec.py | 84 -- computelib/config.py | 36 - computelib/exceptions.py | 22 - computelib/session.py | 52 -- computelib/utils/__init__.py | 1 - computelib/utils/mac.py | 10 - computelib/utils/xml.py | 73 -- computelib/vm/__init__.py | 3 - computelib/vm/base.py | 30 - computelib/vm/guest_agent.py | 179 ----- computelib/vm/installer.py | 168 ---- computelib/vm/virtual_machine.py | 233 ------ computelib/volume/__init__.py | 2 - computelib/volume/storage_pool.py | 70 -- computelib/volume/volume.py | 80 -- config.toml | 29 - docs/Makefile | 20 + docs/make.bat | 35 + docs/source/_templates/versioning.html | 8 + docs/source/conf.py | 35 + docs/source/index.rst | 15 + poetry.lock | 716 +++++++++++++++++- pyproject.toml | 50 +- 46 files changed, 2698 insertions(+), 1282 deletions(-) create mode 100644 compute/__init__.py create mode 100644 compute/__main__.py rename computelib/cli/volctl.py => compute/cli/__init__.py (100%) create mode 100644 compute/cli/_create.py create mode 100644 compute/cli/control.py create mode 100644 compute/exceptions.py create mode 100644 compute/instance/__init__.py create mode 100644 compute/instance/guest_agent.py create mode 100644 compute/instance/instance.py create mode 100644 compute/instance/schemas.py create mode 100644 compute/session.py create mode 100644 compute/storage/__init__.py create mode 100644 compute/storage/pool.py create mode 100644 compute/storage/volume.py create mode 100644 compute/utils/__init__.py create mode 100644 compute/utils/config_loader.py create mode 100644 compute/utils/identifiers.py create mode 100644 compute/utils/units.py delete mode 100644 computelib/__init__.py delete mode 100644 computelib/cli/vmctl.py delete mode 100644 computelib/cli/vmexec.py delete mode 100644 computelib/config.py delete mode 100644 computelib/exceptions.py delete mode 100644 computelib/session.py delete mode 100644 computelib/utils/__init__.py delete mode 100644 computelib/utils/mac.py delete mode 100644 computelib/utils/xml.py delete mode 100644 computelib/vm/__init__.py delete mode 100644 computelib/vm/base.py delete mode 100644 computelib/vm/guest_agent.py delete mode 100644 computelib/vm/installer.py delete mode 100644 computelib/vm/virtual_machine.py delete mode 100644 computelib/volume/__init__.py delete mode 100644 computelib/volume/storage_pool.py delete mode 100644 computelib/volume/volume.py delete mode 100644 config.toml create mode 100644 docs/Makefile create mode 100644 docs/make.bat create mode 100644 docs/source/_templates/versioning.html create mode 100644 docs/source/conf.py create mode 100644 docs/source/index.rst diff --git a/.gitignore b/.gitignore index 145fa54..ff3ae0f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,8 +1,7 @@ +dist/ +docs/build/ +.ruff_cache/ __pycache__/ *.pyc *~ -dom* -na -dist/ -P@ssw0rd *.todo diff --git a/Makefile b/Makefile index 35f8839..2444722 100644 --- a/Makefile +++ b/Makefile @@ -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:~ diff --git a/README.md b/README.md index 65e307a..e5ebab3 100644 --- a/README.md +++ b/README.md @@ -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 +- [ ] Снапшоты diff --git a/compute/__init__.py b/compute/__init__.py new file mode 100644 index 0000000..601cf05 --- /dev/null +++ b/compute/__init__.py @@ -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 diff --git a/compute/__main__.py b/compute/__main__.py new file mode 100644 index 0000000..c6467ef --- /dev/null +++ b/compute/__main__.py @@ -0,0 +1,6 @@ +"""Command line interface for compute module.""" + +from compute.cli import control + + +control.cli() diff --git a/computelib/cli/volctl.py b/compute/cli/__init__.py similarity index 100% rename from computelib/cli/volctl.py rename to compute/cli/__init__.py diff --git a/compute/cli/_create.py b/compute/cli/_create.py new file mode 100644 index 0000000..6f29566 --- /dev/null +++ b/compute/cli/_create.py @@ -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) diff --git a/compute/cli/control.py b/compute/cli/control.py new file mode 100644 index 0000000..e289581 --- /dev/null +++ b/compute/cli/control.py @@ -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() diff --git a/compute/exceptions.py b/compute/exceptions.py new file mode 100644 index 0000000..331b800 --- /dev/null +++ b/compute/exceptions.py @@ -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") diff --git a/compute/instance/__init__.py b/compute/instance/__init__.py new file mode 100644 index 0000000..100c1c5 --- /dev/null +++ b/compute/instance/__init__.py @@ -0,0 +1,3 @@ +from .guest_agent import GuestAgent +from .instance import Instance, InstanceConfig +from .schemas import InstanceSchema diff --git a/compute/instance/guest_agent.py b/compute/instance/guest_agent.py new file mode 100644 index 0000000..dd02126 --- /dev/null +++ b/compute/instance/guest_agent.py @@ -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 diff --git a/compute/instance/instance.py b/compute/instance/instance.py new file mode 100644 index 0000000..f5bfe75 --- /dev/null +++ b/compute/instance/instance.py @@ -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() diff --git a/compute/instance/schemas.py b/compute/instance/schemas.py new file mode 100644 index 0000000..f19853f --- /dev/null +++ b/compute/instance/schemas.py @@ -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 diff --git a/compute/session.py b/compute/session.py new file mode 100644 index 0000000..7e2945a --- /dev/null +++ b/compute/session.py @@ -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()] diff --git a/compute/storage/__init__.py b/compute/storage/__init__.py new file mode 100644 index 0000000..5090edd --- /dev/null +++ b/compute/storage/__init__.py @@ -0,0 +1,2 @@ +from .pool import StoragePool +from .volume import DiskConfig, Volume, VolumeConfig diff --git a/compute/storage/pool.py b/compute/storage/pool.py new file mode 100644 index 0000000..ea2e169 --- /dev/null +++ b/compute/storage/pool.py @@ -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()] diff --git a/compute/storage/volume.py b/compute/storage/volume.py new file mode 100644 index 0000000..2460bd2 --- /dev/null +++ b/compute/storage/volume.py @@ -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() diff --git a/compute/utils/__init__.py b/compute/utils/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/compute/utils/config_loader.py b/compute/utils/config_loader.py new file mode 100644 index 0000000..5763d03 --- /dev/null +++ b/compute/utils/config_loader.py @@ -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 diff --git a/compute/utils/identifiers.py b/compute/utils/identifiers.py new file mode 100644 index 0000000..335017f --- /dev/null +++ b/compute/utils/identifiers.py @@ -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)) diff --git a/compute/utils/units.py b/compute/utils/units.py new file mode 100644 index 0000000..6146a59 --- /dev/null +++ b/compute/utils/units.py @@ -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]) diff --git a/computelib/__init__.py b/computelib/__init__.py deleted file mode 100644 index d0c0f5f..0000000 --- a/computelib/__init__.py +++ /dev/null @@ -1,5 +0,0 @@ -from .config import ConfigLoader -from .exceptions import * -from .session import LibvirtSession -from .vm import * -from .volume import * diff --git a/computelib/cli/vmctl.py b/computelib/cli/vmctl.py deleted file mode 100644 index 17f6ca4..0000000 --- a/computelib/cli/vmctl.py +++ /dev/null @@ -1,108 +0,0 @@ -""" -Manage virtual machines. - -Usage: na-vmctl [options] status - na-vmctl [options] is-running - na-vmctl [options] start - na-vmctl [options] shutdown - na-vmctl [options] set-vcpus - na-vmctl [options] set-memory - na-vmctl [options] list [-a|--all] - -Options: - -c, --config config file [default: /etc/node-agent/config.yaml] - -l, --loglvl 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[''] - - 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() diff --git a/computelib/cli/vmexec.py b/computelib/cli/vmexec.py deleted file mode 100644 index 3bc5f11..0000000 --- a/computelib/cli/vmexec.py +++ /dev/null @@ -1,84 +0,0 @@ -""" -Execute shell commands on guest via guest agent. - -Usage: na-vmexec [options] - -Options: - -c, --config config file [default: /etc/node-agent/config.yaml] - -l, --loglvl logging level - -s, --shell guest shell [default: /bin/sh] - -t, --timeout QEMU timeout in seconds to stop polling command status [default: 60] - -p, --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[''] - - 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[''] - - 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() diff --git a/computelib/config.py b/computelib/config.py deleted file mode 100644 index 300d324..0000000 --- a/computelib/config.py +++ /dev/null @@ -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() diff --git a/computelib/exceptions.py b/computelib/exceptions.py deleted file mode 100644 index 12ca8e8..0000000 --- a/computelib/exceptions.py +++ /dev/null @@ -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.""" diff --git a/computelib/session.py b/computelib/session.py deleted file mode 100644 index fc5660e..0000000 --- a/computelib/session.py +++ /dev/null @@ -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() diff --git a/computelib/utils/__init__.py b/computelib/utils/__init__.py deleted file mode 100644 index b860656..0000000 --- a/computelib/utils/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import mac, xml diff --git a/computelib/utils/mac.py b/computelib/utils/mac.py deleted file mode 100644 index 3fedc93..0000000 --- a/computelib/utils/mac.py +++ /dev/null @@ -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)) diff --git a/computelib/utils/xml.py b/computelib/utils/xml.py deleted file mode 100644 index c2060ae..0000000 --- a/computelib/utils/xml.py +++ /dev/null @@ -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) diff --git a/computelib/vm/__init__.py b/computelib/vm/__init__.py deleted file mode 100644 index 9589876..0000000 --- a/computelib/vm/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -from .guest_agent import GuestAgent -from .installer import VirtualMachineInstaller -from .virtual_machine import VirtualMachine diff --git a/computelib/vm/base.py b/computelib/vm/base.py deleted file mode 100644 index 052c198..0000000 --- a/computelib/vm/base.py +++ /dev/null @@ -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 diff --git a/computelib/vm/guest_agent.py b/computelib/vm/guest_agent.py deleted file mode 100644 index 4faf037..0000000 --- a/computelib/vm/guest_agent.py +++ /dev/null @@ -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 diff --git a/computelib/vm/installer.py b/computelib/vm/installer.py deleted file mode 100644 index afdd740..0000000 --- a/computelib/vm/installer.py +++ /dev/null @@ -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 = '' - elif mode == 'maximum': - xml = '' - elif mode in ['host-model', 'custom']: - cpus = self.domcaps.xpath( - f'/domainCapabilities/cpu/mode[@name="{mode}"]')[0] - cpus.tag = 'cpu' - for attr in cpus.attrib.keys(): - del cpus.attrib[attr] - arch = etree.SubElement(cpus, 'arch') - arch.text = self.arch - xmlcpus = etree.tostring( - cpus, encoding='unicode', pretty_print=True) - xml = self.connection.baselineHypervisorCPU( - self.emulator, self.arch, self.machine, self.virttype, [xmlcpus]) - else: - raise ValueError( - f'CPU mode must be in {[v.value for v in CPUMode]}, ' - f"but passed '{mode}'") - return textwrap.indent(xml, ' ' * 2) - - def _define(self, xml: str) -> None: - self.connection.defineXML(xml) diff --git a/computelib/vm/virtual_machine.py b/computelib/vm/virtual_machine.py deleted file mode 100644 index 5d9794a..0000000 --- a/computelib/vm/virtual_machine.py +++ /dev/null @@ -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 diff --git a/computelib/volume/__init__.py b/computelib/volume/__init__.py deleted file mode 100644 index c70cd93..0000000 --- a/computelib/volume/__init__.py +++ /dev/null @@ -1,2 +0,0 @@ -from .storage_pool import StoragePool -from .volume import DiskInfo, Volume, VolumeInfo diff --git a/computelib/volume/storage_pool.py b/computelib/volume/storage_pool.py deleted file mode 100644 index 4bac4a4..0000000 --- a/computelib/volume/storage_pool.py +++ /dev/null @@ -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()] diff --git a/computelib/volume/volume.py b/computelib/volume/volume.py deleted file mode 100644 index f10772b..0000000 --- a/computelib/volume/volume.py +++ /dev/null @@ -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() diff --git a/config.toml b/config.toml deleted file mode 100644 index 4815e28..0000000 --- a/config.toml +++ /dev/null @@ -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' diff --git a/docs/Makefile b/docs/Makefile new file mode 100644 index 0000000..d0c3cbf --- /dev/null +++ b/docs/Makefile @@ -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) diff --git a/docs/make.bat b/docs/make.bat new file mode 100644 index 0000000..747ffb7 --- /dev/null +++ b/docs/make.bat @@ -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 diff --git a/docs/source/_templates/versioning.html b/docs/source/_templates/versioning.html new file mode 100644 index 0000000..7c5ab14 --- /dev/null +++ b/docs/source/_templates/versioning.html @@ -0,0 +1,8 @@ +{% if versions %} +

{{ _('Versions') }}

+ +{% endif %} diff --git a/docs/source/conf.py b/docs/source/conf.py new file mode 100644 index 0000000..cbd5572 --- /dev/null +++ b/docs/source/conf.py @@ -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", +] diff --git a/docs/source/index.rst b/docs/source/index.rst new file mode 100644 index 0000000..a2a34dc --- /dev/null +++ b/docs/source/index.rst @@ -0,0 +1,15 @@ +Compute Service +=============== + +Документация библиотеки для управления Compute-инстансами. + +.. toctree:: + :maxdepth: 2 + :caption: Contents: + +Индексы и таблицы +----------------- + +* :ref:`genindex` +* :ref:`modindex` +* :ref:`search` diff --git a/poetry.lock b/poetry.lock index 34965bd..5b9eb5a 100644 --- a/poetry.lock +++ b/poetry.lock @@ -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" diff --git a/pyproject.toml b/pyproject.toml index 311e817..a4dd55d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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 "] 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