various improvements
This commit is contained in:
parent
2870708365
commit
ffa7605201
7
.gitignore
vendored
7
.gitignore
vendored
@ -1,8 +1,7 @@
|
||||
dist/
|
||||
docs/build/
|
||||
.ruff_cache/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*~
|
||||
dom*
|
||||
na
|
||||
dist/
|
||||
P@ssw0rd
|
||||
*.todo
|
||||
|
34
Makefile
34
Makefile
@ -1,17 +1,33 @@
|
||||
SRC = computelib/
|
||||
SRC = compute/
|
||||
DIST = dist/
|
||||
DOCS_SRC = docs/source/
|
||||
DOCS_BUILD = docs/build/
|
||||
|
||||
.PHONY: docs
|
||||
|
||||
all: build
|
||||
|
||||
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:~
|
||||
|
97
README.md
97
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
|
||||
- [ ] Снапшоты
|
||||
|
7
compute/__init__.py
Normal file
7
compute/__init__.py
Normal file
@ -0,0 +1,7 @@
|
||||
"""Compute Service library."""
|
||||
|
||||
__version__ = '0.1.0'
|
||||
|
||||
from .instance import Instance, InstanceConfig, InstanceSchema
|
||||
from .session import Session
|
||||
from .storage import StoragePool, Volume, VolumeConfig
|
6
compute/__main__.py
Normal file
6
compute/__main__.py
Normal file
@ -0,0 +1,6 @@
|
||||
"""Command line interface for compute module."""
|
||||
|
||||
from compute.cli import control
|
||||
|
||||
|
||||
control.cli()
|
26
compute/cli/_create.py
Normal file
26
compute/cli/_create.py
Normal file
@ -0,0 +1,26 @@
|
||||
import argparse
|
||||
|
||||
from compute import Session
|
||||
from compute.utils import identifiers
|
||||
|
||||
|
||||
def _create_instance(session: Session, args: argparse.Namespace) -> None:
|
||||
"""
|
||||
Умолчания (достать информацию из либвирта):
|
||||
- arch
|
||||
- machine
|
||||
- emulator
|
||||
- CPU
|
||||
- cpu_vendor
|
||||
- cpu_model
|
||||
- фичи
|
||||
- max_memory
|
||||
- max_vcpus
|
||||
|
||||
(сегнерировать):
|
||||
- MAC адрес
|
||||
- boot_order = ('cdrom', 'hd')
|
||||
- title = ''
|
||||
- name = uuid.uuid4().hex
|
||||
"""
|
||||
print(args)
|
319
compute/cli/control.py
Normal file
319
compute/cli/control.py
Normal file
@ -0,0 +1,319 @@
|
||||
"""Command line interface."""
|
||||
|
||||
import argparse
|
||||
import logging
|
||||
import os
|
||||
import shlex
|
||||
import sys
|
||||
|
||||
import libvirt
|
||||
|
||||
from compute import __version__
|
||||
from compute.exceptions import (
|
||||
ComputeServiceError,
|
||||
GuestAgentTimeoutExceededError,
|
||||
)
|
||||
from compute.instance import GuestAgent
|
||||
from compute.session import Session
|
||||
|
||||
from ._create import _create_instance
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
log_levels = logging.getLevelNamesMapping()
|
||||
|
||||
env_log_level = os.getenv('CMP_LOG')
|
||||
|
||||
libvirt.registerErrorHandler(
|
||||
lambda userdata, err: None, # noqa: ARG005
|
||||
ctx=None,
|
||||
)
|
||||
|
||||
|
||||
class Table:
|
||||
"""Minimalistic text table constructor."""
|
||||
|
||||
def __init__(self, whitespace: str | None = None):
|
||||
"""Initialise Table."""
|
||||
self.whitespace = whitespace or '\t'
|
||||
self.header = []
|
||||
self._rows = []
|
||||
self._table = ''
|
||||
|
||||
def row(self, row: list) -> None:
|
||||
"""Add table row."""
|
||||
self._rows.append([str(col) for col in row])
|
||||
|
||||
def rows(self, rows: list[list]) -> None:
|
||||
"""Add multiple rows."""
|
||||
for row in rows:
|
||||
self.row(row)
|
||||
|
||||
def __str__(self) -> str:
|
||||
"""Build table and return."""
|
||||
widths = [max(map(len, col)) for col in zip(*self._rows, strict=True)]
|
||||
self._rows.insert(0, [str(h).upper() for h in self.header])
|
||||
for row in self._rows:
|
||||
self._table += self.whitespace.join(
|
||||
(
|
||||
val.ljust(width)
|
||||
for val, width in zip(row, widths, strict=True)
|
||||
)
|
||||
)
|
||||
self._table += '\n'
|
||||
return self._table.strip()
|
||||
|
||||
|
||||
def _list_instances(session: Session) -> None:
|
||||
table = Table()
|
||||
table.header = ['NAME', 'STATE']
|
||||
for instance in session.list_instances():
|
||||
table.row(
|
||||
[
|
||||
instance.name,
|
||||
instance.status,
|
||||
]
|
||||
)
|
||||
print(table)
|
||||
sys.exit()
|
||||
|
||||
|
||||
def _exec_guest_agent_command(
|
||||
session: Session, args: argparse.Namespace
|
||||
) -> None:
|
||||
instance = session.get_instance(args.instance)
|
||||
ga = GuestAgent(instance.domain, timeout=args.timeout)
|
||||
arguments = args.arguments.copy()
|
||||
if len(arguments) > 1:
|
||||
arguments = [shlex.join(arguments)]
|
||||
if not args.no_cmd_string:
|
||||
arguments.insert(0, '-c')
|
||||
stdin = None
|
||||
if not sys.stdin.isatty():
|
||||
stdin = sys.stdin.read()
|
||||
try:
|
||||
output = ga.guest_exec(
|
||||
path=args.shell,
|
||||
args=arguments,
|
||||
env=args.env,
|
||||
stdin=stdin,
|
||||
capture_output=True,
|
||||
decode_output=True,
|
||||
poll=True,
|
||||
)
|
||||
except GuestAgentTimeoutExceededError as e:
|
||||
sys.exit(
|
||||
f'{e}. NOTE: command may still running in guest, '
|
||||
f'PID={ga.last_pid}'
|
||||
)
|
||||
if output.stderr:
|
||||
print(output.stderr.strip(), file=sys.stderr)
|
||||
if output.stdout:
|
||||
print(output.stdout.strip(), file=sys.stdout)
|
||||
sys.exit(output.exitcode)
|
||||
|
||||
|
||||
def main(session: Session, args: argparse.Namespace) -> None:
|
||||
"""Perform actions."""
|
||||
match args.command:
|
||||
case 'create':
|
||||
_create_instance(session, args)
|
||||
case 'exec':
|
||||
_exec_guest_agent_command(session, args)
|
||||
case 'ls':
|
||||
_list_instances(session)
|
||||
case 'start':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.start()
|
||||
case 'shutdown':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.shutdown(args.method)
|
||||
case 'reboot':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.reboot()
|
||||
case 'reset':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.reset()
|
||||
case 'status':
|
||||
instance = session.get_instance(args.instance)
|
||||
print(instance.status)
|
||||
case 'setvcpus':
|
||||
instance = session.get_instance(args.instance)
|
||||
instance.set_vcpus(args.nvcpus, live=True)
|
||||
|
||||
|
||||
def cli() -> None: # noqa: PLR0915
|
||||
"""Parse command line arguments."""
|
||||
root = argparse.ArgumentParser(
|
||||
prog='compute',
|
||||
description='manage compute instances and storage volumes.',
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
root.add_argument(
|
||||
'-v',
|
||||
'--verbose',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='enable verbose mode',
|
||||
)
|
||||
root.add_argument(
|
||||
'-c',
|
||||
'--connect',
|
||||
metavar='URI',
|
||||
default='qemu:///system',
|
||||
help='libvirt connection URI',
|
||||
)
|
||||
root.add_argument(
|
||||
'-l',
|
||||
'--log-level',
|
||||
metavar='LEVEL',
|
||||
choices=log_levels,
|
||||
help='log level [envvar: CMP_LOG]',
|
||||
)
|
||||
root.add_argument(
|
||||
'-V',
|
||||
'--version',
|
||||
action='version',
|
||||
version=__version__,
|
||||
)
|
||||
subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
|
||||
|
||||
# create command
|
||||
create = subparsers.add_parser('create', help='create compute instance')
|
||||
create.add_argument('image', nargs='?')
|
||||
create.add_argument('--name', help='instance name, used as ID')
|
||||
create.add_argument('--title', help='human-understandable instance title')
|
||||
create.add_argument('--desc', default='', help='instance description')
|
||||
create.add_argument('--memory', type=int, help='memory in MiB')
|
||||
create.add_argument('--max-memory', type=int, help='max memory in MiB')
|
||||
create.add_argument('--vcpus', type=int)
|
||||
create.add_argument('--max-vcpus', type=int)
|
||||
create.add_argument('--cpu-vendor')
|
||||
create.add_argument('--cpu-model')
|
||||
create.add_argument(
|
||||
'--cpu-emulation-mode',
|
||||
choices=['host-passthrough', 'host-model', 'custom'],
|
||||
default='host-passthrough',
|
||||
)
|
||||
create.add_argument('--cpu-features')
|
||||
create.add_argument('--cpu-topology')
|
||||
create.add_argument('--mahine')
|
||||
create.add_argument('--emulator')
|
||||
create.add_argument('--arch')
|
||||
create.add_argument('--boot-order')
|
||||
create.add_argument('--volume')
|
||||
create.add_argument('-f', '--file', help='create instance from YAML')
|
||||
|
||||
# exec subcommand
|
||||
execute = subparsers.add_parser(
|
||||
'exec',
|
||||
help='execute command in guest via guest agent',
|
||||
description=(
|
||||
'NOTE: any argument after instance name will be passed into '
|
||||
'guest as shell command.'
|
||||
),
|
||||
)
|
||||
execute.add_argument('instance')
|
||||
execute.add_argument('arguments', nargs=argparse.REMAINDER)
|
||||
execute.add_argument(
|
||||
'-t',
|
||||
'--timeout',
|
||||
type=int,
|
||||
default=60,
|
||||
help=(
|
||||
'waiting time in seconds for a command to be executed '
|
||||
'in guest, 60 sec by default'
|
||||
),
|
||||
)
|
||||
execute.add_argument(
|
||||
'-s',
|
||||
'--shell',
|
||||
default='/bin/sh',
|
||||
help='path to executable in guest, /bin/sh by default',
|
||||
)
|
||||
execute.add_argument(
|
||||
'-e',
|
||||
'--env',
|
||||
type=str,
|
||||
nargs='?',
|
||||
action='append',
|
||||
help='environment variables to pass to executable in guest',
|
||||
)
|
||||
execute.add_argument(
|
||||
'-n',
|
||||
'--no-cmd-string',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help=(
|
||||
"do not append '-c' option to arguments list, suitable "
|
||||
'for non-shell executables and other specific cases.'
|
||||
),
|
||||
)
|
||||
|
||||
# ls subcommand
|
||||
listall = subparsers.add_parser('ls', help='list instances')
|
||||
listall.add_argument(
|
||||
'-a',
|
||||
'--all',
|
||||
action='store_true',
|
||||
default=False,
|
||||
help='list all instances including inactive',
|
||||
)
|
||||
|
||||
# start subcommand
|
||||
start = subparsers.add_parser('start', help='start instance')
|
||||
start.add_argument('instance')
|
||||
|
||||
# shutdown subcommand
|
||||
shutdown = subparsers.add_parser('shutdown', help='shutdown instance')
|
||||
shutdown.add_argument('instance')
|
||||
shutdown.add_argument(
|
||||
'-m',
|
||||
'--method',
|
||||
choices=['soft', 'normal', 'hard', 'unsafe'],
|
||||
default='normal',
|
||||
help='use shutdown method',
|
||||
)
|
||||
|
||||
# reboot subcommand
|
||||
reboot = subparsers.add_parser('reboot', help='reboot instance')
|
||||
reboot.add_argument('instance')
|
||||
|
||||
# reset subcommand
|
||||
reset = subparsers.add_parser('reset', help='reset instance')
|
||||
reset.add_argument('instance')
|
||||
|
||||
# status subcommand
|
||||
status = subparsers.add_parser('status', help='display instance status')
|
||||
status.add_argument('instance')
|
||||
|
||||
# setvcpus subcommand
|
||||
setvcpus = subparsers.add_parser('setvcpus', help='set vCPU number')
|
||||
setvcpus.add_argument('instance')
|
||||
setvcpus.add_argument('nvcpus', type=int)
|
||||
|
||||
# Run parser
|
||||
args = root.parse_args()
|
||||
if args.command is None:
|
||||
root.print_help()
|
||||
sys.exit()
|
||||
|
||||
# Set logging level
|
||||
log_level = args.log_level or env_log_level
|
||||
if log_level in log_levels:
|
||||
logging.basicConfig(level=log_levels[log_level])
|
||||
|
||||
# Perform actions
|
||||
try:
|
||||
with Session(args.connect) as session:
|
||||
main(session, args)
|
||||
except ComputeServiceError as e:
|
||||
sys.exit(f'error: {e}')
|
||||
except (KeyboardInterrupt, SystemExit):
|
||||
sys.exit()
|
||||
except Exception as e: # noqa: BLE001
|
||||
sys.exit(f'unexpected error {type(e)}: {e}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
49
compute/exceptions.py
Normal file
49
compute/exceptions.py
Normal file
@ -0,0 +1,49 @@
|
||||
"""Compute Service exceptions."""
|
||||
|
||||
|
||||
class ComputeServiceError(Exception):
|
||||
"""Basic exception class for Compute."""
|
||||
|
||||
|
||||
class ConfigLoaderError(ComputeServiceError):
|
||||
"""Something went wrong when loading configuration."""
|
||||
|
||||
|
||||
class SessionError(ComputeServiceError):
|
||||
"""Something went wrong while connecting to libvirtd."""
|
||||
|
||||
|
||||
class GuestAgentError(ComputeServiceError):
|
||||
"""Something went wring when QEMU Guest Agent call."""
|
||||
|
||||
|
||||
class GuestAgentUnavailableError(GuestAgentError):
|
||||
"""Guest agent is not connected or is unavailable."""
|
||||
|
||||
|
||||
class GuestAgentTimeoutExceededError(GuestAgentError):
|
||||
"""QEMU timeout exceeded."""
|
||||
|
||||
def __init__(self, msg: int):
|
||||
"""Initialise GuestAgentTimeoutExceededError."""
|
||||
super().__init__(f'QEMU timeout ({msg} sec) exceeded')
|
||||
|
||||
|
||||
class GuestAgentCommandNotSupportedError(GuestAgentError):
|
||||
"""Guest agent command is not supported or blacklisted on guest."""
|
||||
|
||||
|
||||
class StoragePoolError(ComputeServiceError):
|
||||
"""Something went wrong when operating with storage pool."""
|
||||
|
||||
|
||||
class InstanceError(ComputeServiceError):
|
||||
"""Something went wrong while interacting with the domain."""
|
||||
|
||||
|
||||
class InstanceNotFoundError(InstanceError):
|
||||
"""Virtual machine or container not found on compute node."""
|
||||
|
||||
def __init__(self, msg: str):
|
||||
"""Initialise InstanceNotFoundError."""
|
||||
super().__init__(f"compute instance '{msg}' not found")
|
3
compute/instance/__init__.py
Normal file
3
compute/instance/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .guest_agent import GuestAgent
|
||||
from .instance import Instance, InstanceConfig
|
||||
from .schemas import InstanceSchema
|
197
compute/instance/guest_agent.py
Normal file
197
compute/instance/guest_agent.py
Normal file
@ -0,0 +1,197 @@
|
||||
"""Manage QEMU guest agent."""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from base64 import b64decode, standard_b64encode
|
||||
from time import sleep, time
|
||||
from typing import NamedTuple
|
||||
|
||||
import libvirt
|
||||
import libvirt_qemu
|
||||
|
||||
from compute.exceptions import (
|
||||
GuestAgentCommandNotSupportedError,
|
||||
GuestAgentError,
|
||||
GuestAgentTimeoutExceededError,
|
||||
GuestAgentUnavailableError,
|
||||
)
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
QEMU_TIMEOUT = 60
|
||||
POLL_INTERVAL = 0.3
|
||||
|
||||
|
||||
class GuestExecOutput(NamedTuple):
|
||||
"""QEMU guest-exec command output."""
|
||||
|
||||
exited: bool | None = None
|
||||
exitcode: int | None = None
|
||||
stdout: str | None = None
|
||||
stderr: str | None = None
|
||||
|
||||
|
||||
class GuestAgent:
|
||||
"""Class for interacting with QEMU guest agent."""
|
||||
|
||||
def __init__(self, domain: libvirt.virDomain, timeout: int | None = None):
|
||||
"""
|
||||
Initialise GuestAgent.
|
||||
|
||||
:param domain: Libvirt domain object
|
||||
:param timeout: QEMU timeout
|
||||
"""
|
||||
self.domain = domain
|
||||
self.timeout = timeout or QEMU_TIMEOUT
|
||||
self.flags = libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
|
||||
self.last_pid = None
|
||||
|
||||
def execute(self, command: dict) -> dict:
|
||||
"""
|
||||
Execute QEMU guest agent command.
|
||||
|
||||
See: https://qemu-project.gitlab.io/qemu/interop/qemu-ga-ref.html
|
||||
|
||||
:param command: QEMU guest agent command as dict
|
||||
:return: Command output
|
||||
:rtype: dict
|
||||
"""
|
||||
log.debug(command)
|
||||
try:
|
||||
output = libvirt_qemu.qemuAgentCommand(
|
||||
self.domain, json.dumps(command), self.timeout, self.flags
|
||||
)
|
||||
return json.loads(output)
|
||||
except libvirt.libvirtError as e:
|
||||
if e.get_error_code() == libvirt.VIR_ERR_AGENT_UNRESPONSIVE:
|
||||
log.exception(
|
||||
'Guest agent is unavailable on instanse=%s', self.name
|
||||
)
|
||||
raise GuestAgentUnavailableError(e) from e
|
||||
raise GuestAgentError(e) from e
|
||||
|
||||
def is_available(self) -> bool:
|
||||
"""
|
||||
Execute guest-ping.
|
||||
|
||||
:return: True or False if guest agent is unreachable.
|
||||
:rtype: bool
|
||||
"""
|
||||
try:
|
||||
if self.execute({'execute': 'guest-ping', 'arguments': {}}):
|
||||
return True
|
||||
except GuestAgentError:
|
||||
return False
|
||||
|
||||
def available_commands(self) -> set[str]:
|
||||
"""Return set of available guest agent commands."""
|
||||
output = self.execute({'execute': 'guest-info', 'arguments': {}})
|
||||
return {
|
||||
cmd['name']
|
||||
for cmd in output['return']['supported_commands']
|
||||
if cmd['enabled'] is True
|
||||
}
|
||||
|
||||
def raise_for_commands(self, commands: list[str]) -> None:
|
||||
"""
|
||||
Check QEMU guest agent command availability.
|
||||
|
||||
Raise exception if command is not available.
|
||||
|
||||
:param commands: List of required commands
|
||||
:raise: GuestAgentCommandNotSupportedError
|
||||
"""
|
||||
for command in commands:
|
||||
if command not in self.available_commands():
|
||||
raise GuestAgentCommandNotSupportedError(command)
|
||||
|
||||
def guest_exec( # noqa: PLR0913
|
||||
self,
|
||||
path: str,
|
||||
args: list[str] | None = None,
|
||||
env: list[str] | None = None,
|
||||
stdin: str | None = None,
|
||||
*,
|
||||
capture_output: bool = False,
|
||||
decode_output: bool = False,
|
||||
poll: bool = False,
|
||||
) -> GuestExecOutput:
|
||||
"""
|
||||
Execute qemu-exec command and return output.
|
||||
|
||||
:param path: Path ot executable on guest.
|
||||
:param arg: List of arguments to pass to executable.
|
||||
:param env: List of environment variables to pass to executable.
|
||||
For example: ``['LANG=C', 'TERM=xterm']``
|
||||
:param stdin: Data to pass to executable STDIN.
|
||||
:param capture_output: Capture command output.
|
||||
:param decode_output: Use base64_decode() to decode command output.
|
||||
Affects only if `capture_output` is True.
|
||||
:param poll: Poll command output. Uses `self.timeout` and
|
||||
POLL_INTERVAL constant.
|
||||
:return: Command output
|
||||
:rtype: GuestExecOutput
|
||||
"""
|
||||
self.raise_for_commands(['guest-exec', 'guest-exec-status'])
|
||||
command = {
|
||||
'execute': 'guest-exec',
|
||||
'arguments': {
|
||||
'path': path,
|
||||
**({'arg': args} if args else {}),
|
||||
**({'env': env} if env else {}),
|
||||
**(
|
||||
{
|
||||
'input-data': standard_b64encode(
|
||||
stdin.encode('utf-8')
|
||||
).decode('utf-8')
|
||||
}
|
||||
if stdin
|
||||
else {}
|
||||
),
|
||||
'capture-output': capture_output,
|
||||
},
|
||||
}
|
||||
output = self.execute(command)
|
||||
self.last_pid = pid = output['return']['pid']
|
||||
command_status = self.guest_exec_status(pid, poll=poll)['return']
|
||||
exited = command_status['exited']
|
||||
exitcode = command_status['exitcode']
|
||||
stdout = command_status.get('out-data', None)
|
||||
stderr = command_status.get('err-data', None)
|
||||
if decode_output:
|
||||
stdout = b64decode(stdout or '').decode('utf-8')
|
||||
stderr = b64decode(stderr or '').decode('utf-8')
|
||||
return GuestExecOutput(exited, exitcode, stdout, stderr)
|
||||
|
||||
def guest_exec_status(self, pid: int, *, poll: bool = False) -> dict:
|
||||
"""
|
||||
Execute guest-exec-status and return output.
|
||||
|
||||
:param pid: PID in guest
|
||||
:param poll: If True poll command status with POLL_INTERVAL
|
||||
:return: Command output
|
||||
:rtype: dict
|
||||
"""
|
||||
self.raise_for_commands(['guest-exec-status'])
|
||||
command = {
|
||||
'execute': 'guest-exec-status',
|
||||
'arguments': {'pid': pid},
|
||||
}
|
||||
if not poll:
|
||||
return self.execute(command)
|
||||
start_time = time()
|
||||
while True:
|
||||
command_status = self.execute(command)
|
||||
if command_status['return']['exited']:
|
||||
break
|
||||
sleep(POLL_INTERVAL)
|
||||
now = time()
|
||||
if now - start_time > self.timeout:
|
||||
raise GuestAgentTimeoutExceededError(self.timeout)
|
||||
log.debug(
|
||||
'Polling command pid=%s finished, time taken: %s seconds',
|
||||
pid,
|
||||
int(time() - start_time),
|
||||
)
|
||||
return command_status
|
551
compute/instance/instance.py
Normal file
551
compute/instance/instance.py
Normal file
@ -0,0 +1,551 @@
|
||||
"""Manage compute instances."""
|
||||
|
||||
__all__ = ['Instance', 'InstanceConfig']
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
from lxml.builder import E
|
||||
|
||||
from compute.exceptions import (
|
||||
GuestAgentCommandNotSupportedError,
|
||||
InstanceError,
|
||||
)
|
||||
from compute.utils import units
|
||||
|
||||
from .guest_agent import GuestAgent
|
||||
from .schemas import CPUSchema, InstanceSchema, NetworkInterfaceSchema
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class InstanceConfig:
|
||||
"""Compute instance description for libvirt."""
|
||||
|
||||
def __init__(self, schema: InstanceSchema):
|
||||
"""
|
||||
Initialise InstanceConfig.
|
||||
|
||||
:param schema: InstanceSchema object
|
||||
"""
|
||||
self.name = schema.name
|
||||
self.title = schema.title
|
||||
self.description = schema.description
|
||||
self.memory = schema.memory
|
||||
self.max_memory = schema.max_memory
|
||||
self.vcpus = schema.vcpus
|
||||
self.max_vcpus = schema.max_vcpus
|
||||
self.cpu = schema.cpu
|
||||
self.machine = schema.machine
|
||||
self.emulator = schema.emulator
|
||||
self.arch = schema.arch
|
||||
self.boot = schema.boot
|
||||
self.network_interfaces = schema.network_interfaces
|
||||
|
||||
def _gen_cpu_xml(self, cpu: CPUSchema) -> etree.Element:
|
||||
xml = E.cpu(match='exact', mode=cpu.emulation_mode)
|
||||
xml.append(E.model(cpu.model, fallback='forbid'))
|
||||
xml.append(E.vendor(cpu.vendor))
|
||||
xml.append(
|
||||
E.topology(
|
||||
sockets=str(cpu.topology.sockets),
|
||||
dies=str(cpu.topology.dies),
|
||||
cores=str(cpu.topology.cores),
|
||||
threads=str(cpu.topology.threads),
|
||||
)
|
||||
)
|
||||
for feature in cpu.features.require:
|
||||
xml.append(E.feature(policy='require', name=feature))
|
||||
for feature in cpu.features.disable:
|
||||
xml.append(E.feature(policy='disable', name=feature))
|
||||
return xml
|
||||
|
||||
def _gen_vcpus_xml(self, vcpus: int, max_vcpus: int) -> etree.Element:
|
||||
xml = E.vcpus()
|
||||
xml.append(E.vcpu(id='0', enabled='yes', hotpluggable='no', order='1'))
|
||||
for i in range(max_vcpus - 1):
|
||||
enabled = 'yes' if (i + 2) <= vcpus else 'no'
|
||||
xml.append(
|
||||
E.vcpu(
|
||||
id=str(i + 1),
|
||||
enabled=enabled,
|
||||
hotpluggable='yes',
|
||||
order=str(i + 2),
|
||||
)
|
||||
)
|
||||
return xml
|
||||
|
||||
def _gen_network_interface_xml(
|
||||
self, interface: NetworkInterfaceSchema
|
||||
) -> etree.Element:
|
||||
return E.interface(
|
||||
E.source(network=interface.source),
|
||||
E.mac(address=interface.mac),
|
||||
type='network',
|
||||
)
|
||||
|
||||
def to_xml(self) -> str:
|
||||
"""Return XML config for libvirt."""
|
||||
xml = E.domain(
|
||||
E.name(self.name),
|
||||
E.title(self.title),
|
||||
E.description(self.description),
|
||||
E.metadata(),
|
||||
E.memory(str(self.memory * 1024), unit='KiB'),
|
||||
E.currentMemory(str(self.memory * 1024), unit='KiB'),
|
||||
type='kvm',
|
||||
)
|
||||
xml.append(
|
||||
E.vcpu(
|
||||
str(self.max_vcpus),
|
||||
placement='static',
|
||||
current=str(self.vcpus),
|
||||
)
|
||||
)
|
||||
xml.append(self._gen_cpu_xml(self.cpu))
|
||||
os = E.os(E.type('hvm', machine=self.machine, arch=self.arch))
|
||||
for dev in self.boot.order:
|
||||
os.append(E.boot(dev=dev))
|
||||
xml.append(os)
|
||||
xml.append(E.features(E.acpi(), E.apic()))
|
||||
xml.append(E.on_poweroff('destroy'))
|
||||
xml.append(E.on_reboot('restart'))
|
||||
xml.append(E.on_crash('restart'))
|
||||
xml.append(
|
||||
E.pm(
|
||||
E('suspend-to-mem', enabled='no'),
|
||||
E('suspend-to-disk', enabled='no'),
|
||||
)
|
||||
)
|
||||
devices = E.devices()
|
||||
devices.append(E.emulator(str(self.emulator)))
|
||||
for interface in self.network_interfaces:
|
||||
devices.append(self._gen_network_interface_xml(interface))
|
||||
devices.append(E.graphics(type='vnc', port='-1', autoport='yes'))
|
||||
devices.append(E.input(type='tablet', bus='usb'))
|
||||
devices.append(
|
||||
E.channel(
|
||||
E.source(mode='bind'),
|
||||
E.target(type='virtio', name='org.qemu.guest_agent.0'),
|
||||
E.address(
|
||||
type='virtio-serial', controller='0', bus='0', port='1'
|
||||
),
|
||||
type='unix',
|
||||
)
|
||||
)
|
||||
devices.append(
|
||||
E.console(E.target(type='serial', port='0'), type='pty')
|
||||
)
|
||||
devices.append(
|
||||
E.video(
|
||||
E.model(type='vga', vram='16384', heads='1', primary='yes')
|
||||
)
|
||||
)
|
||||
xml.append(devices)
|
||||
return etree.tostring(xml, encoding='unicode', pretty_print=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class InstanceInfo:
|
||||
state: str
|
||||
max_memory: int
|
||||
memory: int
|
||||
nproc: int
|
||||
cputime: int
|
||||
|
||||
|
||||
class DeviceConfig:
|
||||
"""Abstract device description class."""
|
||||
|
||||
|
||||
class Instance:
|
||||
"""Class for manipulating compute instance."""
|
||||
|
||||
def __init__(self, domain: libvirt.virDomain):
|
||||
"""
|
||||
Initialise Instance.
|
||||
|
||||
:prop domain libvirt.virDomain:
|
||||
:prop connection libvirt.virConnect:
|
||||
:prop name str:
|
||||
:prop guest_agent GuestAgent:
|
||||
|
||||
:param domain: libvirt domain object
|
||||
"""
|
||||
self.domain = domain
|
||||
self.connection = domain.connect()
|
||||
self.name = domain.name()
|
||||
self.guest_agent = GuestAgent(domain)
|
||||
|
||||
def _expand_instance_state(self, state: int) -> str:
|
||||
states = {
|
||||
libvirt.VIR_DOMAIN_NOSTATE: 'nostate',
|
||||
libvirt.VIR_DOMAIN_RUNNING: 'running',
|
||||
libvirt.VIR_DOMAIN_BLOCKED: 'blocked',
|
||||
libvirt.VIR_DOMAIN_PAUSED: 'paused',
|
||||
libvirt.VIR_DOMAIN_SHUTDOWN: 'shutdown',
|
||||
libvirt.VIR_DOMAIN_SHUTOFF: 'shutoff',
|
||||
libvirt.VIR_DOMAIN_CRASHED: 'crashed',
|
||||
libvirt.VIR_DOMAIN_PMSUSPENDED: 'pmsuspended',
|
||||
}
|
||||
return states[state]
|
||||
|
||||
@property
|
||||
def info(self) -> InstanceInfo:
|
||||
"""
|
||||
Return instance info.
|
||||
|
||||
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainInfo
|
||||
"""
|
||||
_info = self.domain.info()
|
||||
return InstanceInfo(
|
||||
state=self._expand_instance_state(_info[0]),
|
||||
max_memory=_info[1],
|
||||
memory=_info[2],
|
||||
nproc=_info[3],
|
||||
cputime=_info[4],
|
||||
)
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""
|
||||
Return instance state: 'running', 'shutoff', etc.
|
||||
|
||||
Reference:
|
||||
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
|
||||
"""
|
||||
try:
|
||||
state, _ = self.domain.state()
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
'Cannot fetch status of ' f'instance={self.name}: {e}'
|
||||
) from e
|
||||
return self._expand_instance_state(state)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Return True if instance is running, else return False."""
|
||||
if self.domain.isActive() != 1:
|
||||
# 0 - is inactive, -1 - is error
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_autostart(self) -> bool:
|
||||
"""Return True if instance autostart is enabled, else return False."""
|
||||
try:
|
||||
return bool(self.domain.autostart())
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
f'Cannot get autostart status for '
|
||||
f'instance={self.name}: {e}'
|
||||
) from e
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start defined instance."""
|
||||
log.info('Starting instnce=%s', self.name)
|
||||
if self.is_running:
|
||||
log.warning(
|
||||
'Already started, nothing to do instance=%s', self.name
|
||||
)
|
||||
return
|
||||
try:
|
||||
self.domain.create()
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
f'Cannot start instance={self.name}: {e}'
|
||||
) from e
|
||||
|
||||
def shutdown(self, method: str | None = None) -> None:
|
||||
"""
|
||||
Shutdown instance.
|
||||
|
||||
Shutdown methods:
|
||||
|
||||
SOFT
|
||||
Use guest agent to shutdown. If guest agent is unavailable
|
||||
NORMAL method will be used.
|
||||
|
||||
NORMAL
|
||||
Use method choosen by hypervisor to shutdown. Usually send ACPI
|
||||
signal to guest OS. OS may ignore ACPI e.g. if guest is hanged.
|
||||
|
||||
HARD
|
||||
Shutdown instance without any guest OS shutdown. This is simular
|
||||
to unplugging machine from power. Internally send SIGTERM to
|
||||
instance process and destroy it gracefully.
|
||||
|
||||
UNSAFE
|
||||
Force shutdown. Internally send SIGKILL to instance process.
|
||||
There is high data corruption risk!
|
||||
|
||||
If method is None NORMAL method will used.
|
||||
|
||||
:param method: Method used to shutdown instance
|
||||
"""
|
||||
methods = {
|
||||
'SOFT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
|
||||
'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT,
|
||||
'HARD': libvirt.VIR_DOMAIN_DESTROY_GRACEFUL,
|
||||
'UNSAFE': libvirt.VIR_DOMAIN_DESTROY_DEFAULT,
|
||||
}
|
||||
if method is None:
|
||||
method = 'NORMAL'
|
||||
if not isinstance(method, str):
|
||||
raise TypeError(
|
||||
f"Shutdown method must be a 'str', not {type(method)}"
|
||||
)
|
||||
method = method.upper()
|
||||
if method not in methods:
|
||||
raise ValueError(f"Unsupported shutdown method: '{method}'")
|
||||
try:
|
||||
if method in ['SOFT', 'NORMAL']:
|
||||
self.domain.shutdownFlags(flags=methods[method])
|
||||
elif method in ['HARD', 'UNSAFE']:
|
||||
self.domain.destroyFlags(flags=methods[method])
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
f'Cannot shutdown instance={self.name} ' f'{method=}: {e}'
|
||||
) from e
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Reset instance.
|
||||
|
||||
Copypaste from libvirt doc:
|
||||
|
||||
Reset a domain immediately without any guest OS shutdown.
|
||||
Reset emulates the power reset button on a machine, where all
|
||||
hardware sees the RST line set and reinitializes internal state.
|
||||
|
||||
Note that there is a risk of data loss caused by reset without any
|
||||
guest OS shutdown.
|
||||
"""
|
||||
try:
|
||||
self.domain.reset()
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
f'Cannot reset instance={self.name}: {e}'
|
||||
) from e
|
||||
|
||||
def reboot(self) -> None:
|
||||
"""Send ACPI signal to guest OS to reboot. OS may ignore this."""
|
||||
try:
|
||||
self.domain.reboot()
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
f'Cannot reboot instance={self.name}: {e}'
|
||||
) from e
|
||||
|
||||
def set_autostart(self, *, enabled: bool) -> None:
|
||||
"""
|
||||
Set autostart flag for instance.
|
||||
|
||||
:param enabled: Bool argument to set or unset autostart flag.
|
||||
"""
|
||||
autostart = 1 if enabled else 0
|
||||
try:
|
||||
self.domain.setAutostart(autostart)
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
f'Cannot set autostart flag for instance={self.name} '
|
||||
f'{autostart=}: {e}'
|
||||
) from e
|
||||
|
||||
def set_vcpus(self, nvcpus: int, *, live: bool = False) -> None:
|
||||
"""
|
||||
Set vCPU number.
|
||||
|
||||
If `live` is True and instance is not currently running vCPUs
|
||||
will set in config and will applied when instance boot.
|
||||
|
||||
NB: Note that if this call is executed before the guest has
|
||||
finished booting, the guest may fail to process the change.
|
||||
|
||||
:param nvcpus: Number of vCPUs
|
||||
:param live: Affect a running instance
|
||||
"""
|
||||
if nvcpus == 0:
|
||||
raise InstanceError(
|
||||
f'Cannot set zero vCPUs for instance={self.name}'
|
||||
)
|
||||
if nvcpus == self.info.nproc:
|
||||
log.warning(
|
||||
'Instance instance=%s already have %s vCPUs, nothing to do',
|
||||
self.name,
|
||||
nvcpus,
|
||||
)
|
||||
return
|
||||
try:
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
self.domain.setVcpusFlags(nvcpus, flags=flags)
|
||||
if live is True:
|
||||
if not self.is_running:
|
||||
log.warning(
|
||||
'Instance is not running, changes applied in '
|
||||
'instance config.'
|
||||
)
|
||||
return
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_LIVE
|
||||
self.domain.setVcpusFlags(nvcpus, flags=flags)
|
||||
if self.guest_agent.is_available():
|
||||
try:
|
||||
self.guest_agent.raise_for_commands(
|
||||
['guest-set-vcpus']
|
||||
)
|
||||
flags = libvirt.VIR_DOMAIN_VCPU_GUEST
|
||||
self.domain.setVcpusFlags(nvcpus, flags=flags)
|
||||
except GuestAgentCommandNotSupportedError:
|
||||
log.warning(
|
||||
'Cannot set vCPUs in guest via agent, you may '
|
||||
'need to apply changes in guest manually.'
|
||||
)
|
||||
else:
|
||||
log.warning(
|
||||
'Cannot set vCPUs in guest OS on instance=%s. '
|
||||
'You may need to apply CPUs in guest manually.',
|
||||
self.name,
|
||||
)
|
||||
except libvirt.libvirtError as e:
|
||||
raise InstanceError(
|
||||
f'Cannot set vCPUs for instance={self.name}: {e}'
|
||||
) from e
|
||||
|
||||
def set_memory(self, memory: int, *, live: bool = False) -> None:
|
||||
"""
|
||||
Set memory.
|
||||
|
||||
If `live` is True and instance is not currently running set memory
|
||||
in config and will applied when instance boot.
|
||||
|
||||
:param memory: Memory value in mebibytes
|
||||
:param live: Affect a running instance
|
||||
"""
|
||||
if memory == 0:
|
||||
raise InstanceError(
|
||||
f'Cannot set zero memory for instance={self.name}'
|
||||
)
|
||||
if live and self.info()['state'] == libvirt.VIR_DOMAIN_RUNNING:
|
||||
flags = (
|
||||
libvirt.VIR_DOMAIN_AFFECT_LIVE
|
||||
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
)
|
||||
else:
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
try:
|
||||
self.domain.setMemoryFlags(
|
||||
memory * 1024, flags=libvirt.VIR_DOMAIN_MEM_MAXIMUM
|
||||
)
|
||||
self.domain.setMemoryFlags(memory * 1024, flags=flags)
|
||||
except libvirt.libvirtError as e:
|
||||
msg = f'Cannot set memory for instance={self.name} {memory=}: {e}'
|
||||
raise InstanceError(msg) from e
|
||||
|
||||
def attach_device(
|
||||
self, device: 'DeviceConfig', *, live: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Attach device to compute instance.
|
||||
|
||||
:param device: Object with device description e.g. DiskConfig
|
||||
:param live: Affect a running instance
|
||||
"""
|
||||
if live and self.is_running:
|
||||
flags = (
|
||||
libvirt.VIR_DOMAIN_AFFECT_LIVE
|
||||
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
)
|
||||
else:
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
self.domain.attachDeviceFlags(device.to_xml(), flags=flags)
|
||||
|
||||
def detach_device(
|
||||
self, device: 'DeviceConfig', *, live: bool = False
|
||||
) -> None:
|
||||
"""
|
||||
Dettach device from compute instance.
|
||||
|
||||
:param device: Object with device description e.g. DiskConfig
|
||||
:param live: Affect a running instance
|
||||
"""
|
||||
if live and self.is_running:
|
||||
flags = (
|
||||
libvirt.VIR_DOMAIN_AFFECT_LIVE
|
||||
| libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
)
|
||||
else:
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
self.domain.detachDeviceFlags(device.to_xml(), flags=flags)
|
||||
|
||||
def resize_volume(
|
||||
self, name: str, capacity: int, unit: units.DataUnit
|
||||
) -> None:
|
||||
"""
|
||||
Resize block device.
|
||||
|
||||
:param name: Disk device name e.g. `vda`, `sda`, etc.
|
||||
:param capacity: Volume capacity in bytes.
|
||||
"""
|
||||
self.domain.blockResize(
|
||||
name,
|
||||
units.to_bytes(capacity, unit=unit),
|
||||
flags=libvirt.VIR_DOMAIN_BLOCK_RESIZE_BYTES,
|
||||
)
|
||||
|
||||
def pause(self) -> None:
|
||||
"""Pause instance."""
|
||||
raise NotImplementedError
|
||||
|
||||
def resume(self) -> None:
|
||||
"""Resume paused instance."""
|
||||
raise NotImplementedError
|
||||
|
||||
def list_ssh_keys(self, user: str) -> list[str]:
|
||||
"""
|
||||
Get list of SSH keys on guest for specific user.
|
||||
|
||||
:param user: Username.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
|
||||
"""
|
||||
Add SSH keys to guest for specific user.
|
||||
|
||||
:param user: Username.
|
||||
:param ssh_keys: List of public SSH keys.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def remove_ssh_keys(self, user: str, ssh_keys: list[str]) -> None:
|
||||
"""
|
||||
Remove SSH keys from guest for specific user.
|
||||
|
||||
:param user: Username.
|
||||
:param ssh_keys: List of public SSH keys.
|
||||
"""
|
||||
raise NotImplementedError
|
||||
|
||||
def set_user_password(self, user: str, password: str) -> None:
|
||||
"""
|
||||
Set new user password in guest OS.
|
||||
|
||||
This action performs by guest agent inside guest.
|
||||
|
||||
:param user: Username.
|
||||
:param password: Password.
|
||||
"""
|
||||
self.domain.setUserPassword(user, password)
|
||||
|
||||
def dump_xml(self, *, inactive: bool = False) -> str:
|
||||
"""Return instance XML description."""
|
||||
flags = libvirt.VIR_DOMAIN_XML_INACTIVE if inactive else 0
|
||||
return self.domain.XMLDesc(flags)
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Undefine instance and delete local volumes."""
|
||||
self.shutdown(method='HARD')
|
||||
self.domain.undefine()
|
126
compute/instance/schemas.py
Normal file
126
compute/instance/schemas.py
Normal file
@ -0,0 +1,126 @@
|
||||
"""Compute instance related objects schemas."""
|
||||
|
||||
import re
|
||||
from enum import StrEnum
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import BaseModel, validator
|
||||
|
||||
from compute.utils.units import DataUnit
|
||||
|
||||
|
||||
class CPUEmulationMode(StrEnum):
|
||||
"""CPU emulation mode enumerated."""
|
||||
|
||||
HOST_PASSTHROUGH = 'host-passthrough'
|
||||
HOST_MODEL = 'host-model'
|
||||
CUSTOM = 'custom'
|
||||
MAXIMUM = 'maximum'
|
||||
|
||||
|
||||
class CPUTopologySchema(BaseModel):
|
||||
"""CPU topology model."""
|
||||
|
||||
sockets: int
|
||||
cores: int
|
||||
threads: int
|
||||
dies: int = 1
|
||||
|
||||
|
||||
class CPUFeaturesSchema(BaseModel):
|
||||
"""CPU features model."""
|
||||
|
||||
require: list[str]
|
||||
disable: list[str]
|
||||
|
||||
|
||||
class CPUSchema(BaseModel):
|
||||
"""CPU model."""
|
||||
|
||||
emulation_mode: CPUEmulationMode
|
||||
model: str
|
||||
vendor: str
|
||||
topology: CPUTopologySchema
|
||||
features: CPUFeaturesSchema
|
||||
|
||||
|
||||
class StorageVolumeType(StrEnum):
|
||||
"""Storage volume types enumeration."""
|
||||
|
||||
FILE = 'file'
|
||||
NETWORK = 'network'
|
||||
|
||||
|
||||
class StorageVolumeCapacitySchema(BaseModel):
|
||||
"""Storage volume capacity field model."""
|
||||
|
||||
value: int
|
||||
unit: DataUnit
|
||||
|
||||
|
||||
class StorageVolumeSchema(BaseModel):
|
||||
"""Storage volume model."""
|
||||
|
||||
type: StorageVolumeType # noqa: A003
|
||||
source: Path
|
||||
target: str
|
||||
capacity: StorageVolumeCapacitySchema
|
||||
readonly: bool = False
|
||||
is_system: bool = False
|
||||
|
||||
|
||||
class NetworkInterfaceSchema(BaseModel):
|
||||
"""Network inerface model."""
|
||||
|
||||
source: str
|
||||
mac: str
|
||||
|
||||
|
||||
class BootOptionsSchema(BaseModel):
|
||||
"""Instance boot settings."""
|
||||
|
||||
order: tuple
|
||||
|
||||
|
||||
class InstanceSchema(BaseModel):
|
||||
"""Compute instance model."""
|
||||
|
||||
name: str
|
||||
title: str
|
||||
description: str
|
||||
memory: int
|
||||
max_memory: int
|
||||
vcpus: int
|
||||
max_vcpus: int
|
||||
cpu: CPUSchema
|
||||
machine: str
|
||||
emulator: Path
|
||||
arch: str
|
||||
image: str
|
||||
boot: BootOptionsSchema
|
||||
volumes: list[StorageVolumeSchema]
|
||||
network_interfaces: list[NetworkInterfaceSchema]
|
||||
|
||||
@validator('name')
|
||||
def _check_name(cls, value: str) -> str: # noqa: N805
|
||||
if not re.match(r'^[a-z0-9_]+$', value):
|
||||
msg = (
|
||||
'Name can contain only lowercase letters, numbers '
|
||||
'and underscore.'
|
||||
)
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@validator('volumes')
|
||||
def _check_volumes(cls, value: list) -> list: # noqa: N805
|
||||
if len([v for v in value if v.is_system is True]) != 1:
|
||||
msg = 'Volumes list must contain one system volume'
|
||||
raise ValueError(msg)
|
||||
return value
|
||||
|
||||
@validator('network_interfaces')
|
||||
def _check_network_interfaces(cls, value: list) -> list: # noqa: N805
|
||||
if not value:
|
||||
msg = 'Network interfaces list must contain at least one element'
|
||||
raise ValueError(msg)
|
||||
return value
|
156
compute/session.py
Normal file
156
compute/session.py
Normal file
@ -0,0 +1,156 @@
|
||||
"""Hypervisor session manager."""
|
||||
|
||||
import logging
|
||||
import os
|
||||
from contextlib import AbstractContextManager
|
||||
from types import TracebackType
|
||||
from typing import Any, NamedTuple
|
||||
from uuid import uuid4
|
||||
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
|
||||
from .exceptions import InstanceNotFoundError, SessionError
|
||||
from .instance import Instance, InstanceConfig, InstanceSchema
|
||||
from .storage import DiskConfig, StoragePool, VolumeConfig
|
||||
from .utils import units
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Capabilities(NamedTuple):
|
||||
"""Store domain capabilities info."""
|
||||
|
||||
arch: str
|
||||
virt: str
|
||||
emulator: str
|
||||
machine: str
|
||||
|
||||
|
||||
class Session(AbstractContextManager):
|
||||
"""Hypervisor session manager."""
|
||||
|
||||
def __init__(self, uri: str | None = None):
|
||||
"""
|
||||
Initialise session with hypervisor.
|
||||
|
||||
:param uri: libvirt connection URI.
|
||||
"""
|
||||
self.IMAGES_POOL = os.getenv('CMP_IMAGES_POOL')
|
||||
self.VOLUMES_POOL = os.getenv('CMP_VOLUMES_POOL')
|
||||
self.uri = uri or 'qemu:///system'
|
||||
self.connection = libvirt.open(self.uri)
|
||||
|
||||
def __enter__(self):
|
||||
"""Return Session object."""
|
||||
return self
|
||||
|
||||
def __exit__(
|
||||
self,
|
||||
exc_type: type[BaseException] | None,
|
||||
exc_value: BaseException | None,
|
||||
exc_traceback: TracebackType | None,
|
||||
):
|
||||
"""Close the connection when leaving the context."""
|
||||
self.close()
|
||||
|
||||
def close(self) -> None:
|
||||
"""Close connection to libvirt daemon."""
|
||||
self.connection.close()
|
||||
|
||||
def capabilities(self) -> Capabilities:
|
||||
"""Return capabilities e.g. arch, virt, emulator, etc."""
|
||||
prefix = '/domainCapabilities'
|
||||
caps = etree.fromstring(self.connection.getDomainCapabilities()) # noqa: S320
|
||||
return Capabilities(
|
||||
arch=caps.xpath(f'{prefix}/arch/text()')[0],
|
||||
virt=caps.xpath(f'{prefix}/domain/text()')[0],
|
||||
emulator=caps.xpath(f'{prefix}/path/text()')[0],
|
||||
machine=caps.xpath(f'{prefix}/machine/text()')[0],
|
||||
)
|
||||
|
||||
def create_instance(self, **kwargs: Any) -> Instance:
|
||||
"""
|
||||
Create and return new compute instance.
|
||||
|
||||
:param name str: Instance name.
|
||||
:param title str: Instance title for humans.
|
||||
:param description str: Some information about instance
|
||||
:param memory int: Memory in MiB.
|
||||
:param max_memory int: Maximum memory in MiB.
|
||||
"""
|
||||
# TODO @ge: create instances in transaction
|
||||
data = InstanceSchema(**kwargs)
|
||||
config = InstanceConfig(data)
|
||||
log.info('Define XML...')
|
||||
log.info(config.to_xml())
|
||||
self.connection.defineXML(config.to_xml())
|
||||
log.info('Getting instance...')
|
||||
instance = self.get_instance(config.name)
|
||||
log.info('Creating volumes...')
|
||||
for volume in data.volumes:
|
||||
log.info('Creating volume=%s', volume)
|
||||
capacity = units.to_bytes(
|
||||
volume.capacity.value, volume.capacity.unit
|
||||
)
|
||||
log.info('Connecting to images pool...')
|
||||
images_pool = self.get_storage_pool(self.IMAGES_POOL)
|
||||
log.info('Connecting to volumes pool...')
|
||||
volumes_pool = self.get_storage_pool(self.VOLUMES_POOL)
|
||||
log.info('Building volume configuration...')
|
||||
# if not volume.source:
|
||||
# В случае если пользователь передаёт source для волюма, следует
|
||||
# в либвирте делать поиск волюма по пути, а не по имени
|
||||
# gen_vol_name
|
||||
# TODO @ge: come up with something else
|
||||
vol_name = f'{config.name}-{volume.target}-{uuid4()}.qcow2'
|
||||
vol_conf = VolumeConfig(
|
||||
name=vol_name,
|
||||
path=str(volumes_pool.path.joinpath(vol_name)),
|
||||
capacity=capacity,
|
||||
)
|
||||
log.info('Volume configuration is:\n %s', vol_conf.to_xml())
|
||||
if volume.is_system is True:
|
||||
log.info(
|
||||
"Volume is marked as 'system', start cloning image..."
|
||||
)
|
||||
log.info('Get image %s', data.image)
|
||||
image = images_pool.get_volume(data.image)
|
||||
log.info('Cloning image into volumes pool...')
|
||||
vol = volumes_pool.clone_volume(image, vol_conf)
|
||||
log.info(
|
||||
'Resize cloned volume to specified size: %s',
|
||||
capacity,
|
||||
)
|
||||
vol.resize(capacity, unit=units.DataUnit.BYTES)
|
||||
else:
|
||||
log.info('Create volume...')
|
||||
volumes_pool.create_volume(vol_conf)
|
||||
log.info('Attaching volume to instance...')
|
||||
instance.attach_device(
|
||||
DiskConfig(path=vol_conf.path, target=volume.target)
|
||||
)
|
||||
return instance
|
||||
|
||||
def get_instance(self, name: str) -> Instance:
|
||||
"""Get compute instance by name."""
|
||||
try:
|
||||
return Instance(self.connection.lookupByName(name))
|
||||
except libvirt.libvirtError as err:
|
||||
if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
|
||||
raise InstanceNotFoundError(name) from err
|
||||
raise SessionError(err) from err
|
||||
|
||||
def list_instances(self) -> list[Instance]:
|
||||
"""List all instances."""
|
||||
return [Instance(dom) for dom in self.connection.listAllDomains()]
|
||||
|
||||
def get_storage_pool(self, name: str) -> StoragePool:
|
||||
"""Get storage pool by name."""
|
||||
# TODO @ge: handle Storage pool not found error
|
||||
return StoragePool(self.connection.storagePoolLookupByName(name))
|
||||
|
||||
def list_storage_pools(self) -> list[StoragePool]:
|
||||
"""List all strage pools."""
|
||||
return [StoragePool(p) for p in self.connection.listStoragePools()]
|
2
compute/storage/__init__.py
Normal file
2
compute/storage/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from .pool import StoragePool
|
||||
from .volume import DiskConfig, Volume, VolumeConfig
|
114
compute/storage/pool.py
Normal file
114
compute/storage/pool.py
Normal file
@ -0,0 +1,114 @@
|
||||
"""Manage storage pools."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import NamedTuple
|
||||
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
|
||||
from compute.exceptions import StoragePoolError
|
||||
|
||||
from .volume import Volume, VolumeConfig
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StoragePoolUsage(NamedTuple):
|
||||
"""Storage pool usage info schema."""
|
||||
|
||||
capacity: int
|
||||
allocation: int
|
||||
available: int
|
||||
|
||||
|
||||
class StoragePool:
|
||||
"""Storage pool manipulating class."""
|
||||
|
||||
def __init__(self, pool: libvirt.virStoragePool):
|
||||
"""Initislise StoragePool."""
|
||||
self.pool = pool
|
||||
self.name = pool.name()
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Return storage pool path."""
|
||||
xml = etree.fromstring(self.pool.XMLDesc()) # noqa: S320
|
||||
return Path(xml.xpath('/pool/target/path/text()')[0])
|
||||
|
||||
def usage(self) -> StoragePoolUsage:
|
||||
"""Return info about storage pool usage."""
|
||||
xml = etree.fromstring(self.pool.XMLDesc()) # noqa: S320
|
||||
return StoragePoolUsage(
|
||||
capacity=int(xml.xpath('/pool/capacity/text()')[0]),
|
||||
allocation=int(xml.xpath('/pool/allocation/text()')[0]),
|
||||
available=int(xml.xpath('/pool/available/text()')[0]),
|
||||
)
|
||||
|
||||
def dump_xml(self) -> str:
|
||||
"""Return storage pool XML description as string."""
|
||||
return self.pool.XMLDesc()
|
||||
|
||||
def refresh(self) -> None:
|
||||
"""Refresh storage pool."""
|
||||
# TODO @ge: handle libvirt asynchronous job related exceptions
|
||||
self.pool.refresh()
|
||||
|
||||
def create_volume(self, vol_conf: VolumeConfig) -> Volume:
|
||||
"""Create storage volume and return Volume instance."""
|
||||
log.info(
|
||||
'Create storage volume vol=%s in pool=%s', vol_conf.name, self.pool
|
||||
)
|
||||
vol = self.pool.createXML(
|
||||
vol_conf.to_xml(),
|
||||
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
|
||||
)
|
||||
return Volume(self.pool, vol)
|
||||
|
||||
def clone_volume(self, src: Volume, dst: VolumeConfig) -> Volume:
|
||||
"""
|
||||
Make storage volume copy.
|
||||
|
||||
:param src: Input volume
|
||||
:param dst: Output volume config
|
||||
"""
|
||||
log.info(
|
||||
'Start volume cloning '
|
||||
'src_pool=%s src_vol=%s dst_pool=%s dst_vol=%s',
|
||||
src.pool_name,
|
||||
src.name,
|
||||
self.pool.name,
|
||||
dst.name,
|
||||
)
|
||||
vol = self.pool.createXMLFrom(
|
||||
dst.to_xml(), # new volume XML description
|
||||
src.vol, # source volume virStorageVol object
|
||||
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
|
||||
)
|
||||
if vol is None:
|
||||
raise StoragePoolError
|
||||
return Volume(self.pool, vol)
|
||||
|
||||
def get_volume(self, name: str) -> Volume | None:
|
||||
"""Lookup and return Volume instance or None."""
|
||||
log.info(
|
||||
'Lookup for storage volume vol=%s in pool=%s', name, self.pool.name
|
||||
)
|
||||
try:
|
||||
vol = self.pool.storageVolLookupByName(name)
|
||||
return Volume(self.pool, vol)
|
||||
except libvirt.libvirtError as e:
|
||||
# TODO @ge: Raise VolumeNotFoundError instead
|
||||
if (
|
||||
e.get_error_domain() == libvirt.VIR_FROM_STORAGE
|
||||
or e.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL
|
||||
):
|
||||
log.exception(e.get_error_message())
|
||||
return None
|
||||
log.exception('unexpected error from libvirt')
|
||||
raise StoragePoolError(e) from e
|
||||
|
||||
def list_volumes(self) -> list[Volume]:
|
||||
"""Return list of volumes in storage pool."""
|
||||
return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()]
|
124
compute/storage/volume.py
Normal file
124
compute/storage/volume.py
Normal file
@ -0,0 +1,124 @@
|
||||
"""Manage storage volumes."""
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from time import time
|
||||
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
from lxml.builder import E
|
||||
|
||||
from compute.utils import units
|
||||
|
||||
|
||||
@dataclass
|
||||
class VolumeConfig:
|
||||
"""
|
||||
Storage volume config builder.
|
||||
|
||||
Generate XML config for creating a volume in a libvirt
|
||||
storage pool.
|
||||
"""
|
||||
|
||||
name: str
|
||||
path: str
|
||||
capacity: int
|
||||
|
||||
def to_xml(self) -> str:
|
||||
"""Return XML config for libvirt."""
|
||||
unixtime = str(int(time()))
|
||||
xml = E.volume(type='file')
|
||||
xml.append(E.name(self.name))
|
||||
xml.append(E.key(self.path))
|
||||
xml.append(E.source())
|
||||
xml.append(E.capacity(str(self.capacity), unit='bytes'))
|
||||
xml.append(E.allocation('0'))
|
||||
xml.append(
|
||||
E.target(
|
||||
E.path(self.path),
|
||||
E.format(type='qcow2'),
|
||||
E.timestamps(
|
||||
E.atime(unixtime), E.mtime(unixtime), E.ctime(unixtime)
|
||||
),
|
||||
E.compat('1.1'),
|
||||
E.features(E.lazy_refcounts()),
|
||||
)
|
||||
)
|
||||
return etree.tostring(xml, encoding='unicode', pretty_print=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiskConfig:
|
||||
"""
|
||||
Disk config builder.
|
||||
|
||||
Generate XML config for attaching or detaching storage volumes
|
||||
to compute instances.
|
||||
"""
|
||||
|
||||
target: str
|
||||
path: str
|
||||
readonly: bool = False
|
||||
|
||||
def to_xml(self) -> str:
|
||||
"""Return XML config for libvirt."""
|
||||
xml = E.disk(type='file', device='disk')
|
||||
xml.append(E.driver(name='qemu', type='qcow2', cache='writethrough'))
|
||||
xml.append(E.source(file=self.path))
|
||||
xml.append(E.target(dev=self.target, bus='virtio'))
|
||||
if self.readonly:
|
||||
xml.append(E.readonly())
|
||||
return etree.tostring(xml, encoding='unicode', pretty_print=True)
|
||||
|
||||
|
||||
class Volume:
|
||||
"""Storage volume manipulating class."""
|
||||
|
||||
def __init__(
|
||||
self, pool: libvirt.virStoragePool, vol: libvirt.virStorageVol
|
||||
):
|
||||
"""
|
||||
Initialise Volume.
|
||||
|
||||
:param pool: libvirt virStoragePool object
|
||||
:param vol: libvirt virStorageVol object
|
||||
"""
|
||||
self.pool = pool
|
||||
self.pool_name = pool.name()
|
||||
self.vol = vol
|
||||
self.name = vol.name()
|
||||
|
||||
@property
|
||||
def path(self) -> Path:
|
||||
"""Return path to volume."""
|
||||
return Path(self.vol.path())
|
||||
|
||||
def dump_xml(self) -> str:
|
||||
"""Return volume XML description as string."""
|
||||
return self.vol.XMLDesc()
|
||||
|
||||
def clone(self, vol_conf: VolumeConfig) -> None:
|
||||
"""
|
||||
Make a copy of volume to the same storage pool.
|
||||
|
||||
:param vol_info VolumeInfo: New storage volume dataclass object
|
||||
"""
|
||||
self.pool.createXMLFrom(
|
||||
vol_conf.to_xml(),
|
||||
self.vol,
|
||||
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA,
|
||||
)
|
||||
|
||||
def resize(self, capacity: int, unit: units.DataUnit) -> None:
|
||||
"""
|
||||
Resize volume.
|
||||
|
||||
:param capacity int: Volume new capacity.
|
||||
:param unit DataUnit: Data unit. Internally converts into bytes.
|
||||
"""
|
||||
# TODO @ge: Check actual volume size before resize
|
||||
self.vol.resize(units.to_bytes(capacity, unit=unit))
|
||||
|
||||
def delete(self) -> None:
|
||||
"""Delete volume from storage pool."""
|
||||
self.vol.delete()
|
0
compute/utils/__init__.py
Normal file
0
compute/utils/__init__.py
Normal file
41
compute/utils/config_loader.py
Normal file
41
compute/utils/config_loader.py
Normal file
@ -0,0 +1,41 @@
|
||||
"""Configuration loader."""
|
||||
|
||||
import tomllib
|
||||
from collections import UserDict
|
||||
from pathlib import Path
|
||||
|
||||
from compute.exceptions import ConfigLoaderError
|
||||
|
||||
|
||||
DEFAULT_CONFIGURATION = {}
|
||||
DEFAULT_CONFIG_FILE = '/etc/computed/computed.toml'
|
||||
|
||||
|
||||
class ConfigLoader(UserDict):
|
||||
"""UserDict for storing configuration."""
|
||||
|
||||
def __init__(self, file: Path | None = None):
|
||||
"""
|
||||
Initialise ConfigLoader.
|
||||
|
||||
:param file: Path to configuration file. If `file` is None
|
||||
use default path from DEFAULT_CONFIG_FILE constant.
|
||||
"""
|
||||
# TODO @ge: load deafult configuration
|
||||
self.file = Path(file) if file else Path(DEFAULT_CONFIG_FILE)
|
||||
super().__init__(self.load())
|
||||
|
||||
def load(self) -> dict:
|
||||
"""Load confguration object from TOML file."""
|
||||
try:
|
||||
with Path(self.file).open('rb') as configfile:
|
||||
return tomllib.load(configfile)
|
||||
# TODO @ge: add config schema validation
|
||||
except tomllib.TOMLDecodeError as tomlerr:
|
||||
raise ConfigLoaderError(
|
||||
f'Bad TOML syntax in config file: {self.file}: {tomlerr}'
|
||||
) from tomlerr
|
||||
except (OSError, ValueError) as readerr:
|
||||
raise ConfigLoaderError(
|
||||
f'Cannot read config file: {self.file}: {readerr}'
|
||||
) from readerr
|
18
compute/utils/identifiers.py
Normal file
18
compute/utils/identifiers.py
Normal file
@ -0,0 +1,18 @@
|
||||
"""Random identificators."""
|
||||
|
||||
# ruff: noqa: S311, C417
|
||||
|
||||
import random
|
||||
|
||||
|
||||
def random_mac() -> str:
|
||||
"""Retrun random MAC address."""
|
||||
mac = [
|
||||
0x00,
|
||||
0x16,
|
||||
0x3E,
|
||||
random.randint(0x00, 0x7F),
|
||||
random.randint(0x00, 0xFF),
|
||||
random.randint(0x00, 0xFF),
|
||||
]
|
||||
return ':'.join(map(lambda x: '%02x' % x, mac))
|
39
compute/utils/units.py
Normal file
39
compute/utils/units.py
Normal file
@ -0,0 +1,39 @@
|
||||
"""Tools for data units convertion."""
|
||||
|
||||
from enum import StrEnum
|
||||
|
||||
|
||||
class DataUnit(StrEnum):
|
||||
"""Data units enumerated."""
|
||||
|
||||
BYTES = 'bytes'
|
||||
KIB = 'KiB'
|
||||
MIB = 'MiB'
|
||||
GIB = 'GiB'
|
||||
TIB = 'TiB'
|
||||
|
||||
|
||||
class InvalidDataUnitError(ValueError):
|
||||
"""Data unit is not valid."""
|
||||
|
||||
def __init__(self, msg: str):
|
||||
"""Initialise InvalidDataUnitError."""
|
||||
super().__init__(
|
||||
f'{msg}, valid units are: {", ".join(list(DataUnit))}'
|
||||
)
|
||||
|
||||
|
||||
def to_bytes(value: int, unit: DataUnit = DataUnit.BYTES) -> int:
|
||||
"""Convert value to bytes. See `DataUnit`."""
|
||||
try:
|
||||
_ = DataUnit(unit)
|
||||
except ValueError as e:
|
||||
raise InvalidDataUnitError(e) from e
|
||||
powers = {
|
||||
DataUnit.BYTES: 0,
|
||||
DataUnit.KIB: 1,
|
||||
DataUnit.MIB: 2,
|
||||
DataUnit.GIB: 3,
|
||||
DataUnit.TIB: 4,
|
||||
}
|
||||
return value * pow(1024, powers[unit])
|
@ -1,5 +0,0 @@
|
||||
from .config import ConfigLoader
|
||||
from .exceptions import *
|
||||
from .session import LibvirtSession
|
||||
from .vm import *
|
||||
from .volume import *
|
@ -1,108 +0,0 @@
|
||||
"""
|
||||
Manage virtual machines.
|
||||
|
||||
Usage: na-vmctl [options] status <machine>
|
||||
na-vmctl [options] is-running <machine>
|
||||
na-vmctl [options] start <machine>
|
||||
na-vmctl [options] shutdown <machine>
|
||||
na-vmctl [options] set-vcpus <machine> <nvcpus>
|
||||
na-vmctl [options] set-memory <machine> <memory>
|
||||
na-vmctl [options] list [-a|--all]
|
||||
|
||||
Options:
|
||||
-c, --config <file> config file [default: /etc/node-agent/config.yaml]
|
||||
-l, --loglvl <lvl> logging level
|
||||
-a, --all list all machines including inactive
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import libvirt
|
||||
from docopt import docopt
|
||||
|
||||
from ..exceptions import VMError, VMNotFound
|
||||
from ..session import LibvirtSession
|
||||
from ..vm import VirtualMachine
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
levels = logging.getLevelNamesMapping()
|
||||
|
||||
libvirt.registerErrorHandler(lambda userdata, err: None, ctx=None)
|
||||
|
||||
|
||||
class Color:
|
||||
RED = '\033[31m'
|
||||
GREEN = '\033[32m'
|
||||
YELLOW = '\033[33m'
|
||||
NONE = '\033[0m'
|
||||
|
||||
|
||||
class Table:
|
||||
|
||||
def __init__(self, whitespace: str = '\t'):
|
||||
self.__rows = []
|
||||
self.__whitespace = whitespace
|
||||
|
||||
def header(self, columns: list):
|
||||
self.__rows.insert(0, [str(col) for col in columns])
|
||||
|
||||
def row(self, row: list):
|
||||
self.__rows.append([str(col) for col in row])
|
||||
|
||||
def rows(self, rows: list):
|
||||
for row in rows:
|
||||
self.row(row)
|
||||
|
||||
def print(self):
|
||||
widths = [max(map(len, col)) for col in zip(*self.__rows)]
|
||||
for row in self.__rows:
|
||||
print(self.__whitespace.join(
|
||||
(val.ljust(width) for val, width in zip(row, widths))))
|
||||
|
||||
|
||||
def cli():
|
||||
args = docopt(__doc__)
|
||||
config = pathlib.Path(args['--config']) or None
|
||||
loglvl = None
|
||||
machine = args['<machine>']
|
||||
|
||||
if args['--loglvl']:
|
||||
loglvl = args['--loglvl'].upper()
|
||||
|
||||
if loglvl in levels:
|
||||
logging.basicConfig(level=levels[loglvl])
|
||||
|
||||
with LibvirtSession() as session:
|
||||
try:
|
||||
if args['list']:
|
||||
table = Table()
|
||||
table.header(['NAME', 'STATE', 'AUTOSTART'])
|
||||
for vm_ in session.list_machines():
|
||||
table.row([vm_.name, vm_.status, vm_.is_autostart])
|
||||
table.print()
|
||||
sys.exit()
|
||||
|
||||
vm = session.get_machine(machine)
|
||||
if args['status']:
|
||||
print(vm.status)
|
||||
if args['is-running']:
|
||||
if vm.is_running:
|
||||
print('running')
|
||||
else:
|
||||
sys.exit(vm.status)
|
||||
if args['start']:
|
||||
vm.start()
|
||||
print(f'{vm.name} started')
|
||||
if args['shutdown']:
|
||||
vm.shutdown('NORMAL')
|
||||
except VMNotFound as nferr:
|
||||
sys.exit(f'{Color.RED}VM {machine} not found.{Color.NONE}')
|
||||
except VMError as vmerr:
|
||||
sys.exit(f'{Color.RED}{vmerr}{Color.NONE}')
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
@ -1,84 +0,0 @@
|
||||
"""
|
||||
Execute shell commands on guest via guest agent.
|
||||
|
||||
Usage: na-vmexec [options] <machine> <command>
|
||||
|
||||
Options:
|
||||
-c, --config <file> config file [default: /etc/node-agent/config.yaml]
|
||||
-l, --loglvl <lvl> logging level
|
||||
-s, --shell <shell> guest shell [default: /bin/sh]
|
||||
-t, --timeout <sec> QEMU timeout in seconds to stop polling command status [default: 60]
|
||||
-p, --pid <PID> PID on guest to poll output
|
||||
"""
|
||||
|
||||
import logging
|
||||
import pathlib
|
||||
import sys
|
||||
|
||||
import libvirt
|
||||
from docopt import docopt
|
||||
|
||||
from ..exceptions import GuestAgentError, VMNotFound
|
||||
from ..session import LibvirtSession
|
||||
from ..vm import GuestAgent
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
levels = logging.getLevelNamesMapping()
|
||||
|
||||
libvirt.registerErrorHandler(lambda userdata, err: None, ctx=None)
|
||||
|
||||
|
||||
class Color:
|
||||
RED = '\033[31m'
|
||||
GREEN = '\033[32m'
|
||||
YELLOW = '\033[33m'
|
||||
NONE = '\033[0m'
|
||||
|
||||
# TODO: Add STDIN support e.g.: cat something.sh | na-vmexec vmname bash
|
||||
|
||||
|
||||
def cli():
|
||||
args = docopt(__doc__)
|
||||
config = pathlib.Path(args['--config']) or None
|
||||
loglvl = None
|
||||
machine = args['<machine>']
|
||||
|
||||
if args['--loglvl']:
|
||||
loglvl = args['--loglvl'].upper()
|
||||
|
||||
if loglvl in levels:
|
||||
logging.basicConfig(level=levels[loglvl])
|
||||
|
||||
with LibvirtSession() as session:
|
||||
shell = args['--shell']
|
||||
cmd = args['<command>']
|
||||
|
||||
try:
|
||||
ga = session.get_guest_agent(machine)
|
||||
exited, exitcode, stdout, stderr = ga.shellexec(
|
||||
cmd, executable=shell, capture_output=True, decode_output=True,
|
||||
timeout=int(args['--timeout']))
|
||||
except GuestAgentError as gaerr:
|
||||
errmsg = f'{Color.RED}{gaerr}{Color.NONE}'
|
||||
if str(gaerr).startswith('Polling command pid='):
|
||||
errmsg = (errmsg + Color.YELLOW +
|
||||
'\n[NOTE: command may still running on guest '
|
||||
'pid={ga.last_pid}]' + Color.NONE)
|
||||
sys.exit(errmsg)
|
||||
except VMNotFound as err:
|
||||
sys.exit(f'{Color.RED}VM {machine} not found{Color.NONE}')
|
||||
|
||||
if not exited:
|
||||
print(Color.YELLOW +
|
||||
'[NOTE: command may still running on guest pid={ga.last_pid}]' +
|
||||
Color.NONE, file=sys.stderr)
|
||||
if stderr:
|
||||
print(stderr.strip(), file=sys.stderr)
|
||||
if stdout:
|
||||
print(stdout.strip(), file=sys.stdout)
|
||||
sys.exit(exitcode)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
cli()
|
@ -1,36 +0,0 @@
|
||||
import os
|
||||
import tomllib
|
||||
from collections import UserDict
|
||||
from pathlib import Path
|
||||
|
||||
from .exceptions import ConfigLoaderError
|
||||
|
||||
|
||||
NODEAGENT_CONFIG_FILE = os.getenv('NODEAGENT_CONFIG_FILE')
|
||||
NODEAGENT_DEFAULT_CONFIG_FILE = '/etc/node-agent/config.toml'
|
||||
|
||||
|
||||
class ConfigLoader(UserDict):
|
||||
|
||||
def __init__(self, file: Path | None = None):
|
||||
if file is None:
|
||||
file = NODEAGENT_CONFIG_FILE or NODEAGENT_DEFAULT_CONFIG_FILE
|
||||
self.file = Path(file)
|
||||
super().__init__(self._load())
|
||||
# todo: load deafult configuration
|
||||
|
||||
def _load(self) -> dict:
|
||||
try:
|
||||
with open(self.file, 'rb') as config:
|
||||
return tomllib.load(config)
|
||||
# todo: config schema validation
|
||||
except tomllib.TOMLDecodeError as tomlerr:
|
||||
raise ConfigLoaderError(
|
||||
f'Bad TOML syntax in config file: {self.file}: {tomlerr}'
|
||||
) from tomlerr
|
||||
except (OSError, ValueError) as readerr:
|
||||
raise ConfigLoaderError(
|
||||
f'Cannot read config file: {self.file}: {readerr}') from readerr
|
||||
|
||||
def reload(self) -> None:
|
||||
self.data = self._load()
|
@ -1,22 +0,0 @@
|
||||
class ConfigLoaderError(Exception):
|
||||
"""Bad config file syntax, unreachable file or bad config schema."""
|
||||
|
||||
|
||||
class LibvirtSessionError(Exception):
|
||||
"""Something went wrong while connecting to libvirtd."""
|
||||
|
||||
|
||||
class VMError(Exception):
|
||||
"""Something went wrong while interacting with the domain."""
|
||||
|
||||
|
||||
class VMNotFound(VMError):
|
||||
"""Virtual machine not found on node."""
|
||||
|
||||
|
||||
class GuestAgentError(Exception):
|
||||
"""Mostly QEMU Guest Agent is not responding."""
|
||||
|
||||
|
||||
class StoragePoolError(Exception):
|
||||
"""Something went wrong when operating with storage pool."""
|
@ -1,52 +0,0 @@
|
||||
from contextlib import AbstractContextManager
|
||||
|
||||
import libvirt
|
||||
|
||||
from .exceptions import LibvirtSessionError, VMNotFound
|
||||
from .vm import GuestAgent, VirtualMachine
|
||||
from .volume import StoragePool
|
||||
|
||||
|
||||
class LibvirtSession(AbstractContextManager):
|
||||
|
||||
def __init__(self, uri: str = 'qemu:///system'):
|
||||
try:
|
||||
self.connection = libvirt.open(uri)
|
||||
except libvirt.libvirtError as err:
|
||||
raise LibvirtSessionError(err) from err
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exception_type, exception_value, exception_traceback):
|
||||
self.close()
|
||||
|
||||
def get_machine(self, name: str) -> VirtualMachine:
|
||||
try:
|
||||
return VirtualMachine(self.connection.lookupByName(name))
|
||||
except libvirt.libvirtError as err:
|
||||
if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
|
||||
raise VMNotFound(name) from err
|
||||
raise LibvirtSessionError(err) from err
|
||||
|
||||
def list_machines(self) -> list[VirtualMachine]:
|
||||
return [VirtualMachine(dom) for dom in
|
||||
self.connection.listAllDomains()]
|
||||
|
||||
def get_guest_agent(self, name: str,
|
||||
timeout: int | None = None) -> GuestAgent:
|
||||
try:
|
||||
return GuestAgent(self.connection.lookupByName(name), timeout)
|
||||
except libvirt.libvirtError as err:
|
||||
if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
|
||||
raise VMNotFound(name) from err
|
||||
raise LibvirtSessionError(err) from err
|
||||
|
||||
def get_storage_pool(self, name: str) -> StoragePool:
|
||||
return StoragePool(self.connection.storagePoolLookupByName(name))
|
||||
|
||||
def list_storage_pools(self) -> list[StoragePool]:
|
||||
return [StoragePool(p) for p in self.connection.listStoragePools()]
|
||||
|
||||
def close(self) -> None:
|
||||
self.connection.close()
|
@ -1 +0,0 @@
|
||||
from . import mac, xml
|
@ -1,10 +0,0 @@
|
||||
import random
|
||||
|
||||
|
||||
def random_mac() -> str:
|
||||
"""Retrun random MAC address."""
|
||||
mac = [0x00, 0x16, 0x3e,
|
||||
random.randint(0x00, 0x7f),
|
||||
random.randint(0x00, 0xff),
|
||||
random.randint(0x00, 0xff)]
|
||||
return ':'.join(map(lambda x: "%02x" % x, mac))
|
@ -1,73 +0,0 @@
|
||||
from lxml.etree import Element, QName, SubElement
|
||||
|
||||
|
||||
class Constructor:
|
||||
"""
|
||||
The XML constructor. This class builds XML configs for libvirt.
|
||||
"""
|
||||
|
||||
def construct_xml(self,
|
||||
tag: dict,
|
||||
namespace: str | None = None,
|
||||
nsprefix: str | None = None,
|
||||
root: Element = None) -> Element:
|
||||
"""
|
||||
Shortly this recursive function transforms dictonary to XML.
|
||||
Return etree.Element built from dict with following structure::
|
||||
|
||||
{
|
||||
'name': 'device', # tag name
|
||||
'text': '', # optional key
|
||||
'values': { # optional key, must be a dict of key-value pairs
|
||||
'type': 'disk'
|
||||
},
|
||||
children: [] # optional key, must be a list of dicts
|
||||
}
|
||||
|
||||
Child elements must have the same structure. Infinite `children` nesting
|
||||
is allowed.
|
||||
"""
|
||||
use_ns = False
|
||||
if isinstance(namespace, str) and isinstance(nsprefix, str):
|
||||
use_ns = True
|
||||
# Create element
|
||||
if root is None:
|
||||
if use_ns:
|
||||
element = Element(QName(namespace, tag['name']),
|
||||
nsmap={nsprefix: namespace})
|
||||
else:
|
||||
element = Element(tag['name'])
|
||||
else:
|
||||
if use_ns:
|
||||
element = SubElement(root, QName(namespace, tag['name']))
|
||||
else:
|
||||
element = SubElement(root, tag['name'])
|
||||
# Fill up element with content
|
||||
if 'text' in tag.keys():
|
||||
element.text = tag['text']
|
||||
if 'values' in tag.keys():
|
||||
for key in tag['values'].keys():
|
||||
element.set(str(key), str(tag['values'][key]))
|
||||
if 'children' in tag.keys():
|
||||
for child in tag['children']:
|
||||
element.append(
|
||||
self.construct_xml(child,
|
||||
namespace=namespace,
|
||||
nsprefix=nsprefix,
|
||||
root=element))
|
||||
return element
|
||||
|
||||
def add_meta(self, xml: Element, data: dict,
|
||||
namespace: str, nsprefix: str) -> None:
|
||||
"""
|
||||
Add metadata to domain. See:
|
||||
https://libvirt.org/formatdomain.html#general-metadata
|
||||
"""
|
||||
metadata = metadata_old = xml.xpath('/domain/metadata')[0]
|
||||
metadata.append(
|
||||
self.construct_xml(
|
||||
data,
|
||||
namespace=namespace,
|
||||
nsprefix=nsprefix,
|
||||
))
|
||||
xml.replace(metadata_old, metadata)
|
@ -1,3 +0,0 @@
|
||||
from .guest_agent import GuestAgent
|
||||
from .installer import VirtualMachineInstaller
|
||||
from .virtual_machine import VirtualMachine
|
@ -1,30 +0,0 @@
|
||||
import libvirt
|
||||
|
||||
from ..exceptions import VMError
|
||||
|
||||
|
||||
class VirtualMachineBase:
|
||||
|
||||
def __init__(self, domain: libvirt.virDomain):
|
||||
self.domain = domain
|
||||
self.domain_name = self._get_domain_name()
|
||||
self.domain_info = self._get_domain_info()
|
||||
|
||||
def _get_domain_name(self):
|
||||
try:
|
||||
return self.domain.name()
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(f'Cannot get domain name: {err}') from err
|
||||
|
||||
def _get_domain_info(self):
|
||||
try:
|
||||
info = self.domain.info()
|
||||
return {
|
||||
'state': info[0],
|
||||
'max_memory': info[1],
|
||||
'memory': info[2],
|
||||
'nproc': info[3],
|
||||
'cputime': info[4]
|
||||
}
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(f'Cannot get domain info: {err}') from err
|
@ -1,179 +0,0 @@
|
||||
import json
|
||||
import logging
|
||||
from base64 import b64decode, standard_b64encode
|
||||
from time import sleep, time
|
||||
|
||||
import libvirt
|
||||
import libvirt_qemu
|
||||
|
||||
from ..exceptions import GuestAgentError
|
||||
from .base import VirtualMachineBase
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
QEMU_TIMEOUT = 60 # in seconds
|
||||
POLL_INTERVAL = 0.3 # also in seconds
|
||||
|
||||
|
||||
class GuestAgent(VirtualMachineBase):
|
||||
"""
|
||||
Interacting with QEMU guest agent. Methods:
|
||||
|
||||
execute()
|
||||
Low-level method for executing QEMU command as dict. Command dict
|
||||
internally converts to JSON. See method docstring for more info.
|
||||
shellexec()
|
||||
High-level method for executing shell commands on guest. Command
|
||||
must be passed as string. Wraps execute() method.
|
||||
|
||||
TODO:
|
||||
check() method. Ping guest agent and check supported commands.
|
||||
"""
|
||||
|
||||
def __init__(self, domain: libvirt.virDomain, timeout: int | None = None,
|
||||
flags: int | None = None):
|
||||
super().__init__(domain)
|
||||
self.timeout = timeout or QEMU_TIMEOUT # timeout for guest agent
|
||||
self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
|
||||
self.last_pid = None
|
||||
|
||||
def execute(self,
|
||||
command: dict,
|
||||
stdin: str | None = None,
|
||||
capture_output: bool = False,
|
||||
decode_output: bool = False,
|
||||
wait: bool = True,
|
||||
timeout: int = QEMU_TIMEOUT
|
||||
) -> tuple[bool | None, int | None, str | None, str | None]:
|
||||
"""
|
||||
Execute command on guest and return output if `capture_output` is True.
|
||||
See https://wiki.qemu.org/Documentation/QMP for QEMU commands reference.
|
||||
If `wait` is True poll guest command output with POLL_INTERVAL. Raise
|
||||
GuestAgentError on `timeout` reached (in seconds).
|
||||
Return values:
|
||||
tuple(
|
||||
exited: bool | None,
|
||||
exitcode: int | None,
|
||||
stdout: str | None,
|
||||
stderr: str | None
|
||||
)
|
||||
stdout and stderr are base64 encoded strings or None. stderr and stdout
|
||||
will be decoded if `decode_output` is True.
|
||||
"""
|
||||
# todo command dict schema validation
|
||||
if capture_output:
|
||||
command['arguments']['capture-output'] = True
|
||||
if isinstance(stdin, str):
|
||||
command['arguments']['input-data'] = standard_b64encode(
|
||||
stdin.encode('utf-8')).decode('utf-8')
|
||||
|
||||
# Execute command on guest
|
||||
cmd_out = self._execute(command)
|
||||
|
||||
if capture_output:
|
||||
self.last_pid = json.loads(cmd_out)['return']['pid']
|
||||
return self._get_cmd_result(
|
||||
self.last_pid,
|
||||
decode_output=decode_output,
|
||||
wait=wait,
|
||||
timeout=timeout,
|
||||
)
|
||||
return None, None, None, None
|
||||
|
||||
def shellexec(self,
|
||||
command: str,
|
||||
stdin: str | None = None,
|
||||
executable: str = '/bin/sh',
|
||||
capture_output: bool = False,
|
||||
decode_output: bool = False,
|
||||
wait: bool = True,
|
||||
timeout: int = QEMU_TIMEOUT
|
||||
) -> tuple[bool | None, int | None, str | None, str | None]:
|
||||
"""
|
||||
Execute command on guest with selected shell. /bin/sh by default.
|
||||
Otherwise of execute() this function brings shell command as string.
|
||||
"""
|
||||
cmd = {
|
||||
'execute': 'guest-exec',
|
||||
'arguments': {
|
||||
'path': executable,
|
||||
'arg': ['-c', command],
|
||||
}
|
||||
}
|
||||
return self.execute(
|
||||
cmd,
|
||||
stdin=stdin,
|
||||
capture_output=capture_output,
|
||||
decode_output=decode_output,
|
||||
wait=wait,
|
||||
timeout=timeout,
|
||||
)
|
||||
|
||||
def poll_pid(self, pid: int):
|
||||
# Нужно цепляться к PID и вывести результат
|
||||
pass
|
||||
|
||||
def _execute(self, command: dict):
|
||||
logging.debug('Execute command: vm=%s cmd=%s', self.domain_name,
|
||||
command)
|
||||
if self.domain_info['state'] != libvirt.VIR_DOMAIN_RUNNING:
|
||||
raise GuestAgentError(
|
||||
f'Cannot execute command: vm={self.domain_name} is not running')
|
||||
try:
|
||||
return libvirt_qemu.qemuAgentCommand(
|
||||
self.domain, # virDomain object
|
||||
json.dumps(command),
|
||||
self.timeout,
|
||||
self.flags,
|
||||
)
|
||||
except libvirt.libvirtError as err:
|
||||
raise GuestAgentError(
|
||||
f'Cannot execute command on vm={self.domain_name}: {err}'
|
||||
) from err
|
||||
|
||||
def _get_cmd_result(
|
||||
self, pid: int, decode_output: bool = False, wait: bool = True,
|
||||
timeout: int = QEMU_TIMEOUT):
|
||||
"""Get executed command result. See GuestAgent.execute() for info."""
|
||||
cmd = {'execute': 'guest-exec-status', 'arguments': {'pid': pid}}
|
||||
|
||||
if not wait:
|
||||
output = json.loads(self._execute(cmd))
|
||||
return self._return_tuple(output, decode=decode_output)
|
||||
|
||||
logger.debug('Start polling command pid=%s on vm=%s', pid,
|
||||
self.domain_name)
|
||||
start_time = time()
|
||||
while True:
|
||||
output = json.loads(self._execute(cmd))
|
||||
if output['return']['exited']:
|
||||
break
|
||||
sleep(POLL_INTERVAL)
|
||||
now = time()
|
||||
if now - start_time > timeout:
|
||||
raise GuestAgentError(
|
||||
f'Polling command pid={pid} on vm={self.domain_name} '
|
||||
f'took longer than {timeout} seconds.'
|
||||
)
|
||||
logger.debug('Polling command pid=%s on vm=%s finished, '
|
||||
'time taken: %s seconds',
|
||||
pid, self.domain_name, int(time() - start_time))
|
||||
return self._return_tuple(output, decode=decode_output)
|
||||
|
||||
def _return_tuple(self, output: dict, decode: bool = False):
|
||||
output = output['return']
|
||||
exited = output['exited']
|
||||
exitcode = output['exitcode']
|
||||
stdout = stderr = None
|
||||
|
||||
if 'out-data' in output.keys():
|
||||
stdout = output['out-data']
|
||||
if 'err-data' in output.keys():
|
||||
stderr = output['err-data']
|
||||
|
||||
if decode:
|
||||
stdout = b64decode(stdout).decode('utf-8') if stdout else None
|
||||
stderr = b64decode(stderr).decode('utf-8') if stderr else None
|
||||
|
||||
return exited, exitcode, stdout, stderr
|
@ -1,168 +0,0 @@
|
||||
import textwrap
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
|
||||
from lxml import etree
|
||||
from lxml.builder import E
|
||||
|
||||
from ..utils import mac
|
||||
from ..volume import DiskInfo, VolumeInfo
|
||||
|
||||
|
||||
@dataclass
|
||||
class VirtualMachineInfo:
|
||||
name: str
|
||||
title: str
|
||||
memory: int
|
||||
vcpus: int
|
||||
machine: str
|
||||
emulator: str
|
||||
arch: str
|
||||
cpu: str # CPU full XML description
|
||||
mac: str
|
||||
description: str = ''
|
||||
boot_order: tuple = ('cdrom', 'hd')
|
||||
|
||||
def to_xml(self) -> str:
|
||||
xml = E.domain(
|
||||
E.name(self.name),
|
||||
E.title(self.title),
|
||||
E.description(self.description),
|
||||
E.metadata(),
|
||||
E.memory(str(self.memory), unit='MB'),
|
||||
E.currentMemory(str(self.memory), unit='MB'),
|
||||
E.vcpu(str(self.vcpus), placement='static'),
|
||||
type='kvm')
|
||||
os = E.os(E.type('hvm', machine=self.machine, arch=self.arch))
|
||||
for dev in self.boot_order:
|
||||
os.append(E.boot(dev=dev))
|
||||
xml.append(os)
|
||||
xml.append(E.features(E.acpi(), E.apic()))
|
||||
xml.append(etree.fromstring(self.cpu))
|
||||
xml.append(E.on_poweroff('destroy'))
|
||||
xml.append(E.on_reboot('restart'))
|
||||
xml.append(E.on_crash('restart'))
|
||||
xml.append(E.pm(
|
||||
E('suspend-to-mem', enabled='no'),
|
||||
E('suspend-to-disk', enabled='no'))
|
||||
)
|
||||
devices = E.devices()
|
||||
devices.append(E.emulator(self.emulator))
|
||||
devices.append(E.interface(
|
||||
E.source(network='default'),
|
||||
E.mac(address=self.mac),
|
||||
type='network'))
|
||||
devices.append(E.graphics(type='vnc', port='-1', autoport='yes'))
|
||||
devices.append(E.input(type='tablet', bus='usb'))
|
||||
devices.append(E.channel(
|
||||
E.source(mode='bind'),
|
||||
E.target(type='virtio', name='org.qemu.guest_agent.0'),
|
||||
E.address(type='virtio-serial', controller='0', bus='0', port='1'),
|
||||
type='unix')
|
||||
)
|
||||
devices.append(E.console(
|
||||
E.target(type='serial', port='0'),
|
||||
type='pty')
|
||||
)
|
||||
devices.append(E.video(
|
||||
E.model(type='vga', vram='16384', heads='1', primary='yes'))
|
||||
)
|
||||
xml.append(devices)
|
||||
return etree.tostring(xml, encoding='unicode', pretty_print=True)
|
||||
|
||||
|
||||
class CPUMode(Enum):
|
||||
HOST_MODEL = 'host-model'
|
||||
HOST_PASSTHROUGH = 'host-passthrough'
|
||||
CUSTOM = 'custom'
|
||||
MAXIMUM = 'maximum'
|
||||
|
||||
@classmethod
|
||||
def default(cls):
|
||||
return cls.HOST_PASSTHROUGH
|
||||
|
||||
|
||||
@dataclass
|
||||
class CPUTopology:
|
||||
sockets: int
|
||||
cores: int
|
||||
threads: int
|
||||
|
||||
def validate(self, vcpus: int) -> None:
|
||||
if self.sockets * self.cores * self.threads == vcpus:
|
||||
return
|
||||
raise ValueError("CPU topology must match the number of 'vcpus'")
|
||||
|
||||
|
||||
class VirtualMachineInstaller:
|
||||
|
||||
def __init__(self, session: 'LibvirtSession'):
|
||||
self.session = session
|
||||
self.connection = session.connection # libvirt.virConnect object
|
||||
self.domcaps = etree.fromstring(
|
||||
self.connection.getDomainCapabilities())
|
||||
self.arch = self.domcaps.xpath('/domainCapabilities/arch/text()')[0]
|
||||
self.virttype = self.domcaps.xpath(
|
||||
'/domainCapabilities/domain/text()')[0]
|
||||
self.emulator = self.domcaps.xpath(
|
||||
'/domainCapabilities/path/text()')[0]
|
||||
self.machine = self.domcaps.xpath(
|
||||
'/domainCapabilities/machine/text()')[0]
|
||||
|
||||
def install(self, data: 'VirtualMachineSchema'):
|
||||
xml_cpu = self._choose_best_cpu(CPUMode.default())
|
||||
xml_vm = VirtualMachineInfo(
|
||||
name=data['name'],
|
||||
title=data['title'],
|
||||
vcpus=data['vcpus'],
|
||||
memory=data['memory'],
|
||||
machine=self.machine,
|
||||
emulator=self.emulator,
|
||||
arch=self.arch,
|
||||
cpu=xml_cpu,
|
||||
mac=mac.random_mac()
|
||||
).to_xml()
|
||||
self._define(xml_vm)
|
||||
storage_pool = self.session.get_storage_pool('default')
|
||||
etalon_vol = storage_pool.get_volume('bookworm.qcow2')
|
||||
new_vol = VolumeInfo(
|
||||
name=data['name'] +
|
||||
'_disk_some_pattern.qcow2',
|
||||
path=storage_pool.path +
|
||||
'/' +
|
||||
data['name'] +
|
||||
'_disk_some_pattern.qcow2',
|
||||
capacity=data['volume']['capacity'])
|
||||
etalon_vol.clone(new_vol)
|
||||
vm = self.session.get_machine(data['name'])
|
||||
vm.attach_device(DiskInfo(path=new_vol.path, target='vda'))
|
||||
vm.set_vcpus(data['vcpus'])
|
||||
vm.set_memory(data['memory'])
|
||||
vm.start()
|
||||
vm.set_autostart(enabled=True)
|
||||
|
||||
def _choose_best_cpu(self, mode: CPUMode) -> str:
|
||||
if mode == 'host-passthrough':
|
||||
xml = '<cpu mode="host-passthrough" migratable="on"/>'
|
||||
elif mode == 'maximum':
|
||||
xml = '<cpu mode="maximum" migratable="on"/>'
|
||||
elif mode in ['host-model', 'custom']:
|
||||
cpus = self.domcaps.xpath(
|
||||
f'/domainCapabilities/cpu/mode[@name="{mode}"]')[0]
|
||||
cpus.tag = 'cpu'
|
||||
for attr in cpus.attrib.keys():
|
||||
del cpus.attrib[attr]
|
||||
arch = etree.SubElement(cpus, 'arch')
|
||||
arch.text = self.arch
|
||||
xmlcpus = etree.tostring(
|
||||
cpus, encoding='unicode', pretty_print=True)
|
||||
xml = self.connection.baselineHypervisorCPU(
|
||||
self.emulator, self.arch, self.machine, self.virttype, [xmlcpus])
|
||||
else:
|
||||
raise ValueError(
|
||||
f'CPU mode must be in {[v.value for v in CPUMode]}, '
|
||||
f"but passed '{mode}'")
|
||||
return textwrap.indent(xml, ' ' * 2)
|
||||
|
||||
def _define(self, xml: str) -> None:
|
||||
self.connection.defineXML(xml)
|
@ -1,233 +0,0 @@
|
||||
import logging
|
||||
|
||||
import libvirt
|
||||
|
||||
from ..exceptions import VMError
|
||||
from ..volume import VolumeInfo
|
||||
from .base import VirtualMachineBase
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class VirtualMachine(VirtualMachineBase):
|
||||
|
||||
@property
|
||||
def name(self):
|
||||
return self.domain_name
|
||||
|
||||
@property
|
||||
def status(self) -> str:
|
||||
"""
|
||||
Return VM state: 'running', 'shutoff', etc. Reference:
|
||||
https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
|
||||
"""
|
||||
try:
|
||||
# libvirt returns list [state: int, reason: int]
|
||||
# https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainGetState
|
||||
state = self.domain.state()[0]
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(
|
||||
f'Cannot fetch VM status vm={self.domain_name}: {err}') from err
|
||||
STATES = {
|
||||
libvirt.VIR_DOMAIN_NOSTATE: 'nostate',
|
||||
libvirt.VIR_DOMAIN_RUNNING: 'running',
|
||||
libvirt.VIR_DOMAIN_BLOCKED: 'blocked',
|
||||
libvirt.VIR_DOMAIN_PAUSED: 'paused',
|
||||
libvirt.VIR_DOMAIN_SHUTDOWN: 'shutdown',
|
||||
libvirt.VIR_DOMAIN_SHUTOFF: 'shutoff',
|
||||
libvirt.VIR_DOMAIN_CRASHED: 'crashed',
|
||||
libvirt.VIR_DOMAIN_PMSUSPENDED: 'pmsuspended',
|
||||
}
|
||||
return STATES.get(state)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
"""Return True if VM is running, else return False."""
|
||||
if self.domain.isActive() != 1:
|
||||
# inactive (0) or error (-1)
|
||||
return False
|
||||
return True
|
||||
|
||||
@property
|
||||
def is_autostart(self) -> bool:
|
||||
"""Return True if VM autostart is enabled, else return False."""
|
||||
try:
|
||||
if self.domain.autostart() == 1:
|
||||
return True
|
||||
return False
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(
|
||||
f'Cannot get autostart status vm={self.domain_name}: {err}'
|
||||
) from err
|
||||
|
||||
def start(self) -> None:
|
||||
"""Start defined VM."""
|
||||
logger.info('Starting VM: vm=%s', self.domain_name)
|
||||
if self.is_running:
|
||||
logger.warning('VM vm=%s is already started, nothing to do',
|
||||
self.domain_name)
|
||||
return
|
||||
try:
|
||||
self.domain.create()
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(
|
||||
f'Cannot start vm={self.domain_name}: {err}') from err
|
||||
|
||||
def shutdown(self, method: str | None = None) -> None:
|
||||
"""
|
||||
Send signal to guest OS to shutdown. Supports several modes:
|
||||
* GUEST_AGENT - use guest agent
|
||||
* NORMAL - use method choosen by hypervisor to shutdown machine
|
||||
* SIGTERM - send SIGTERM to QEMU process, destroy machine gracefully
|
||||
* SIGKILL - send SIGKILL to QEMU process. May corrupt guest data!
|
||||
If mode is not passed use 'NORMAL' mode.
|
||||
"""
|
||||
METHODS = {
|
||||
'GUEST_AGENT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
|
||||
'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT,
|
||||
'SIGTERM': libvirt.VIR_DOMAIN_DESTROY_GRACEFUL,
|
||||
'SIGKILL': libvirt.VIR_DOMAIN_DESTROY_DEFAULT
|
||||
}
|
||||
if method is None:
|
||||
method = 'NORMAL'
|
||||
if not isinstance(method, str):
|
||||
raise ValueError(f"Mode must be a 'str', not {type(method)}")
|
||||
if method.upper() not in METHODS:
|
||||
raise ValueError(f"Unsupported mode: '{method}'")
|
||||
try:
|
||||
if method in ['GUEST_AGENT', 'NORMAL']:
|
||||
self.domain.shutdownFlags(flags=METHODS.get(method))
|
||||
elif method in ['SIGTERM', 'SIGKILL']:
|
||||
self.domain.destroyFlags(flags=METHODS.get(method))
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(f'Cannot shutdown vm={self.domain_name} with '
|
||||
f'method={method}: {err}') from err
|
||||
|
||||
def reset(self) -> None:
|
||||
"""
|
||||
Copypaste from libvirt doc:
|
||||
|
||||
Reset a domain immediately without any guest OS shutdown.
|
||||
Reset emulates the power reset button on a machine, where all
|
||||
hardware sees the RST line set and reinitializes internal state.
|
||||
|
||||
Note that there is a risk of data loss caused by reset without any
|
||||
guest OS shutdown.
|
||||
"""
|
||||
try:
|
||||
self.domain.reset()
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(
|
||||
f'Cannot reset vm={self.domain_name}: {err}') from err
|
||||
|
||||
def reboot(self) -> None:
|
||||
"""Send ACPI signal to guest OS to reboot. OS may ignore this."""
|
||||
try:
|
||||
self.domain.reboot()
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(
|
||||
f'Cannot reboot vm={self.domain_name}: {err}') from err
|
||||
|
||||
def set_autostart(self, enable: bool) -> None:
|
||||
"""
|
||||
Configure VM to be automatically started when the host machine boots.
|
||||
"""
|
||||
if enable:
|
||||
autostart_flag = 1
|
||||
else:
|
||||
autostart_flag = 0
|
||||
try:
|
||||
self.domain.setAutostart(autostart_flag)
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(f'Cannot set autostart vm={self.domain_name} '
|
||||
f'autostart={autostart_flag}: {err}') from err
|
||||
|
||||
def set_vcpus(self, nvcpus: int, hotplug: bool = False):
|
||||
"""
|
||||
Set vCPUs for VM. If `hotplug` is True set vCPUs on running VM.
|
||||
If VM is not running set `hotplug` to False. If `hotplug` is True
|
||||
and VM is not currently running vCPUs will set in config and will
|
||||
applied when machine boot.
|
||||
|
||||
NB: Note that if this call is executed before the guest has
|
||||
finished booting, the guest may fail to process the change.
|
||||
"""
|
||||
if nvcpus == 0:
|
||||
raise VMError(f'Cannot set zero vCPUs vm={self.domain_name}')
|
||||
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
|
||||
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
|
||||
libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
else:
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
try:
|
||||
self.domain.setVcpusFlags(nvcpus, flags=flags)
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(
|
||||
f'Cannot set vCPUs for vm={self.domain_name}: {err}') from err
|
||||
|
||||
def set_memory(self, memory: int, hotplug: bool = False):
|
||||
"""
|
||||
Set momory for VM. `memory` must be passed in mebibytes. Internally
|
||||
converted to kibibytes. If `hotplug` is True set memory for running
|
||||
VM, else set memory in config and will applied when machine boot.
|
||||
If `hotplug` is True and machine is not currently running set memory
|
||||
in config.
|
||||
"""
|
||||
if memory == 0:
|
||||
raise VMError(f'Cannot set zero memory vm={self.domain_name}')
|
||||
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
|
||||
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
|
||||
libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
else:
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
try:
|
||||
self.domain.setMemoryFlags(memory * 1024,
|
||||
libvirt.VIR_DOMAIN_MEM_MAXIMUM)
|
||||
self.domain.setMemoryFlags(memory * 1024, flags=flags)
|
||||
except libvirt.libvirtError as err:
|
||||
raise VMError(
|
||||
f'Cannot set memory for vm={self.domain_name} {memory=}: {err}') from err
|
||||
|
||||
def attach_device(self, device_info: 'DeviceInfo', hotplug: bool = False):
|
||||
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
|
||||
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
|
||||
libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
else:
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
self.domain.attachDeviceFlags(device_info.to_xml(), flags=flags)
|
||||
|
||||
def detach_device(self, device_info: 'DeviceInfo', hotplug: bool = False):
|
||||
if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
|
||||
flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
|
||||
libvirt.VIR_DOMAIN_AFFECT_CONFIG)
|
||||
else:
|
||||
flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
|
||||
self.domain.detachDeviceFlags(device_info.to_xml(), flags=flags)
|
||||
|
||||
def resize_volume(self, vol_info: VolumeInfo, online: bool = False):
|
||||
# Этот метод должен принимать описание волюма и в зависимости от
|
||||
# флага online вызывать virStorageVolResize или virDomainBlockResize
|
||||
# https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainBlockResize
|
||||
pass
|
||||
|
||||
def list_ssh_keys(self, user: str):
|
||||
pass
|
||||
|
||||
def set_ssh_keys(self, user: str):
|
||||
pass
|
||||
|
||||
def remove_ssh_keys(self, user: str):
|
||||
pass
|
||||
|
||||
def set_user_password(self, user: str, password: str) -> None:
|
||||
self.domain.setUserPassword(user, password)
|
||||
|
||||
def dump_xml(self) -> str:
|
||||
return self.domain.XMLDesc()
|
||||
|
||||
def delete(self, delete_volumes: bool = False) -> None:
|
||||
"""Undefine VM."""
|
||||
self.shutdown(method='SIGTERM')
|
||||
self.domain.undefine()
|
||||
# todo: delete local volumes
|
@ -1,2 +0,0 @@
|
||||
from .storage_pool import StoragePool
|
||||
from .volume import DiskInfo, Volume, VolumeInfo
|
@ -1,70 +0,0 @@
|
||||
import logging
|
||||
from collections import namedtuple
|
||||
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
|
||||
from ..exceptions import StoragePoolError
|
||||
from .volume import Volume, VolumeInfo
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class StoragePool:
|
||||
def __init__(self, pool: libvirt.virStoragePool):
|
||||
self.pool = pool
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.pool.name()
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
xml = etree.fromstring(self.pool.XMLDesc())
|
||||
return xml.xpath('/pool/target/path/text()')[0]
|
||||
|
||||
@property
|
||||
def usage(self) -> 'StoragePoolUsage':
|
||||
xml = etree.fromstring(self.pool.XMLDesc())
|
||||
StoragePoolUsage = namedtuple('StoagePoolUsage',
|
||||
['capacity', 'allocation', 'available'])
|
||||
return StoragePoolUsage(
|
||||
capacity=int(xml.xpath('/pool/capacity/text()')[0])
|
||||
allocation=int(xml.xpath('/pool/allocation/text()')[0])
|
||||
available=int(xml.xpath('/pool/available/text()')[0]))
|
||||
|
||||
def dump_xml(self) -> str:
|
||||
return self.pool.XMLDesc()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.pool.refresh()
|
||||
|
||||
def create_volume(self, vol_info: VolumeInfo) -> Volume:
|
||||
"""
|
||||
Create storage volume and return Volume instance.
|
||||
"""
|
||||
logger.info('Create storage volume vol=%s in pool=%s',
|
||||
vol_info.name, self.pool)
|
||||
vol = self.pool.createXML(
|
||||
vol_info.to_xml(),
|
||||
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA)
|
||||
return Volume(self.pool, vol)
|
||||
|
||||
def get_volume(self, name: str) -> Volume | None:
|
||||
"""Lookup and return Volume instance or None."""
|
||||
logger.info('Lookup for storage volume vol=%s in pool=%s',
|
||||
name, self.pool.name)
|
||||
try:
|
||||
vol = self.pool.storageVolLookupByName(name)
|
||||
return Volume(self.pool, vol)
|
||||
except libvirt.libvirtError as err:
|
||||
if (err.get_error_domain() == libvirt.VIR_FROM_STORAGE or
|
||||
err.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL):
|
||||
logger.error(err.get_error_message())
|
||||
return None
|
||||
logger.error('libvirt error: %s' err)
|
||||
raise StoragePoolError(f'libvirt error: {err}') from err
|
||||
|
||||
def list_volumes(self) -> list[Volume]:
|
||||
return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()]
|
@ -1,80 +0,0 @@
|
||||
from dataclasses import dataclass
|
||||
from time import time
|
||||
|
||||
import libvirt
|
||||
from lxml import etree
|
||||
from lxml.builder import E
|
||||
|
||||
|
||||
@dataclass
|
||||
class VolumeInfo:
|
||||
name: str
|
||||
path: str
|
||||
capacity: int
|
||||
|
||||
def to_xml(self) -> str:
|
||||
unixtime = str(int(time()))
|
||||
xml = E.volume(type='file')
|
||||
xml.append(E.name(self.name))
|
||||
xml.append(E.key(self.path))
|
||||
xml.append(E.source())
|
||||
xml.append(E.capacity(str(self.capacity * 1024 * 1024), unit='bytes'))
|
||||
xml.append(E.allocation('0'))
|
||||
xml.append(E.target(
|
||||
E.path(self.path),
|
||||
E.format(type='qcow2'),
|
||||
E.timestamps(
|
||||
E.atime(unixtime),
|
||||
E.mtime(unixtime),
|
||||
E.ctime(unixtime)),
|
||||
E.compat('1.1'),
|
||||
E.features(E.lazy_refcounts())
|
||||
))
|
||||
return etree.tostring(xml, encoding='unicode', pretty_print=True)
|
||||
|
||||
|
||||
@dataclass
|
||||
class DiskInfo:
|
||||
target: str
|
||||
path: str
|
||||
readonly: bool = False
|
||||
|
||||
def to_xml(self) -> str:
|
||||
xml = E.disk(type='file', device='disk')
|
||||
xml.append(E.driver(name='qemu', type='qcow2', cache='writethrough'))
|
||||
xml.append(E.source(file=self.path))
|
||||
xml.append(E.target(dev=self.target, bus='virtio'))
|
||||
if self.readonly:
|
||||
xml.append(E.readonly())
|
||||
return etree.tostring(xml, encoding='unicode', pretty_print=True)
|
||||
|
||||
|
||||
class Volume:
|
||||
def __init__(self, pool: libvirt.virStoragePool,
|
||||
vol: libvirt.virStorageVol):
|
||||
self.pool = pool
|
||||
self.vol = vol
|
||||
|
||||
@property
|
||||
def name(self) -> str:
|
||||
return self.vol.name()
|
||||
|
||||
@property
|
||||
def path(self) -> str:
|
||||
return self.vol.path()
|
||||
|
||||
def dump_xml(self) -> str:
|
||||
return self.vol.XMLDesc()
|
||||
|
||||
def clone(self, vol_info: VolumeInfo) -> None:
|
||||
self.pool.createXMLFrom(
|
||||
vol_info.to_xml(),
|
||||
self.vol,
|
||||
flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA)
|
||||
|
||||
def resize(self, capacity: int):
|
||||
"""Resize volume to `capacity`. Unit is mebibyte."""
|
||||
self.vol.resize(capacity * 1024 * 1024)
|
||||
|
||||
def delete(self) -> None:
|
||||
self.vol.delete()
|
29
config.toml
29
config.toml
@ -1,29 +0,0 @@
|
||||
[libvirt]
|
||||
uri = 'qemu:///system'
|
||||
|
||||
[logging]
|
||||
level = 'INFO'
|
||||
driver = 'file'
|
||||
file = '/var/log/compute/compute.log'
|
||||
|
||||
[[storages.pools]]
|
||||
name = 'ssd-nvme'
|
||||
enabled = true
|
||||
default = true
|
||||
path = '/vm-volumes/ssd-nvme'
|
||||
|
||||
[[storages.pools]]
|
||||
name = 'hdd'
|
||||
enabled = true
|
||||
path = '/vm-volumes/hdd'
|
||||
|
||||
[[storages.pools]]
|
||||
name = 'images'
|
||||
enabled = true
|
||||
path = '/vm-images/vendor'
|
||||
|
||||
[virtual_machine.defaults]
|
||||
autostart = true
|
||||
start = true
|
||||
cpu_vendor = 'Intel'
|
||||
cpu_model = 'Broadwell'
|
20
docs/Makefile
Normal file
20
docs/Makefile
Normal file
@ -0,0 +1,20 @@
|
||||
# Minimal makefile for Sphinx documentation
|
||||
#
|
||||
|
||||
# You can set these variables from the command line, and also
|
||||
# from the environment for the first two.
|
||||
SPHINXOPTS ?=
|
||||
SPHINXBUILD ?= sphinx-build
|
||||
SOURCEDIR = source
|
||||
BUILDDIR = build
|
||||
|
||||
# Put it first so that "make" without argument is like "make help".
|
||||
help:
|
||||
@$(SPHINXBUILD) -M help "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
||||
|
||||
.PHONY: help Makefile
|
||||
|
||||
# Catch-all target: route all unknown targets to Sphinx using the new
|
||||
# "make mode" option. $(O) is meant as a shortcut for $(SPHINXOPTS).
|
||||
%: Makefile
|
||||
@$(SPHINXBUILD) -M $@ "$(SOURCEDIR)" "$(BUILDDIR)" $(SPHINXOPTS) $(O)
|
35
docs/make.bat
Normal file
35
docs/make.bat
Normal file
@ -0,0 +1,35 @@
|
||||
@ECHO OFF
|
||||
|
||||
pushd %~dp0
|
||||
|
||||
REM Command file for Sphinx documentation
|
||||
|
||||
if "%SPHINXBUILD%" == "" (
|
||||
set SPHINXBUILD=sphinx-build
|
||||
)
|
||||
set SOURCEDIR=source
|
||||
set BUILDDIR=build
|
||||
|
||||
%SPHINXBUILD% >NUL 2>NUL
|
||||
if errorlevel 9009 (
|
||||
echo.
|
||||
echo.The 'sphinx-build' command was not found. Make sure you have Sphinx
|
||||
echo.installed, then set the SPHINXBUILD environment variable to point
|
||||
echo.to the full path of the 'sphinx-build' executable. Alternatively you
|
||||
echo.may add the Sphinx directory to PATH.
|
||||
echo.
|
||||
echo.If you don't have Sphinx installed, grab it from
|
||||
echo.https://www.sphinx-doc.org/
|
||||
exit /b 1
|
||||
)
|
||||
|
||||
if "%1" == "" goto help
|
||||
|
||||
%SPHINXBUILD% -M %1 %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
goto end
|
||||
|
||||
:help
|
||||
%SPHINXBUILD% -M help %SOURCEDIR% %BUILDDIR% %SPHINXOPTS% %O%
|
||||
|
||||
:end
|
||||
popd
|
8
docs/source/_templates/versioning.html
Normal file
8
docs/source/_templates/versioning.html
Normal file
@ -0,0 +1,8 @@
|
||||
{% if versions %}
|
||||
<h3>{{ _('Versions') }}</h3>
|
||||
<ul>
|
||||
{%- for item in versions %}
|
||||
<li><a href="{{ item.url }}">{{ item.name }}</a></li>
|
||||
{%- endfor %}
|
||||
</ul>
|
||||
{% endif %}
|
35
docs/source/conf.py
Normal file
35
docs/source/conf.py
Normal file
@ -0,0 +1,35 @@
|
||||
# Configuration file for the Sphinx documentation builder.
|
||||
#
|
||||
# For the full list of built-in configuration values, see the documentation:
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html
|
||||
|
||||
# -- Project information -----------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information
|
||||
|
||||
project = 'Compute'
|
||||
copyright = '2023, Compute Authors'
|
||||
author = 'Compute Authors'
|
||||
release = '0.1.0'
|
||||
|
||||
# -- General configuration ---------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#general-configuration
|
||||
|
||||
extensions = []
|
||||
|
||||
templates_path = ['_templates']
|
||||
exclude_patterns = []
|
||||
|
||||
language = 'ru'
|
||||
|
||||
extensions = [
|
||||
"sphinx_multiversion",
|
||||
]
|
||||
|
||||
# -- Options for HTML output -------------------------------------------------
|
||||
# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output
|
||||
|
||||
html_theme = 'alabaster'
|
||||
html_static_path = ['_static']
|
||||
html_sidebars = [
|
||||
"versioning.html",
|
||||
]
|
15
docs/source/index.rst
Normal file
15
docs/source/index.rst
Normal file
@ -0,0 +1,15 @@
|
||||
Compute Service
|
||||
===============
|
||||
|
||||
Документация библиотеки для управления Compute-инстансами.
|
||||
|
||||
.. toctree::
|
||||
:maxdepth: 2
|
||||
:caption: Contents:
|
||||
|
||||
Индексы и таблицы
|
||||
-----------------
|
||||
|
||||
* :ref:`genindex`
|
||||
* :ref:`modindex`
|
||||
* :ref:`search`
|
716
poetry.lock
generated
716
poetry.lock
generated
@ -1,16 +1,231 @@
|
||||
# This file is automatically @generated by Poetry 1.4.2 and should not be changed by hand.
|
||||
|
||||
[[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"
|
||||
|
@ -1,7 +1,7 @@
|
||||
[tool.poetry]
|
||||
name = "computelib"
|
||||
name = "compute"
|
||||
version = "0.1.0"
|
||||
description = "Compute Node Agent library"
|
||||
description = "Library built on top of libvirt for Compute Service"
|
||||
authors = ["ge <ge@nixhacks.net>"]
|
||||
readme = "README.md"
|
||||
|
||||
@ -9,22 +9,46 @@ readme = "README.md"
|
||||
python = "^3.11"
|
||||
libvirt-python = "9.0.0"
|
||||
lxml = "^4.9.2"
|
||||
docopt = "^0.6.2"
|
||||
pydantic = "1.10.4"
|
||||
|
||||
[tool.poetry.scripts]
|
||||
na-vmctl = "computelib.cli.vmctl:cli"
|
||||
na-vmexec = "computelib.cli.vmexec:cli"
|
||||
compute = "compute.cli.control:cli"
|
||||
|
||||
[tool.poetry.group.dev.dependencies]
|
||||
ruff = "^0.1.3"
|
||||
isort = "^5.12.0"
|
||||
|
||||
|
||||
[tool.poetry.group.docs.dependencies]
|
||||
sphinx = "^7.2.6"
|
||||
sphinx-autobuild = "^2021.3.14"
|
||||
sphinx-multiversion = "^0.2.4"
|
||||
|
||||
[build-system]
|
||||
requires = ["poetry-core"]
|
||||
build-backend = "poetry.core.masonry.api"
|
||||
|
||||
[tool.pylint."MESSAGES CONTROL"]
|
||||
disable = [
|
||||
"invalid-name",
|
||||
"missing-module-docstring",
|
||||
"missing-class-docstring",
|
||||
"missing-function-docstring",
|
||||
"import-error",
|
||||
"too-many-arguments",
|
||||
[tool.ruff]
|
||||
line-length = 79
|
||||
indent-width = 4
|
||||
target-version = "py311"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["ALL"]
|
||||
ignore = [
|
||||
"Q000", "Q003", "D211", "D212", "ANN101", "ISC001", "COM812",
|
||||
"D203", "ANN204", "T201",
|
||||
"EM102", "TRY003", # maybe not ignore?
|
||||
"TD003", "TD006", "FIX002", # todo strings linting
|
||||
]
|
||||
exclude = ["__init__.py"]
|
||||
|
||||
[tool.ruff.lint.flake8-annotations]
|
||||
mypy-init-return = true
|
||||
allow-star-arg-any = true
|
||||
|
||||
[tool.ruff.format]
|
||||
quote-style = "single"
|
||||
|
||||
[tool.ruff.isort]
|
||||
lines-after-imports = 2
|
||||
|
Loading…
Reference in New Issue
Block a user