commit b608d882656cdee939a6c78f77f835eaea167d5e Author: ge Date: Sat Jun 17 20:07:50 2023 +0300 init diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ca06d0d --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +__pycache__/ +*.pyc +*~ diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..6d657d4 --- /dev/null +++ b/Makefile @@ -0,0 +1,8 @@ +all: build + +build: + poetry build + +clean: + [ -d dist/ ] && rm -rf dist/ || true + find . -type d -name __pycache__ -exec rm -rf {} \; > /dev/null 2>&1 || true diff --git a/README.md b/README.md new file mode 100644 index 0000000..6292649 --- /dev/null +++ b/README.md @@ -0,0 +1,27 @@ +# Node Agent + +Агент для работы на ворк-нодах. + +Пока взаимодействовать можно только так (через [test.py](test.py)): + +``` +sudo env NODEAGENT_CONFIG_FILE=$PWD/configuration.toml python test.py +``` + +# Модули + +Основной класс тут `NodeAgent`. Через него осуществляется доступ ко всем методам. + +- `base` тут базовый класс. +- `main` тут объявлен `NodeAgent`. +- `exceptions` тут исключения. +- `config` тут понятно. +- `vm` тут объявлен класс `VirtualMachine` с базовыми методами для виртуалок. Генерацию XML для дефайна ВМ следует сделать в отдельном модуле. + +# TODO + +Нужно что-то придумать с обработкой ошибок. Сейчас на неожиданности я вызываю исключения, нужно некритичные из них обработать, чтобы приложение не падало при обращении к несуществующему домену или нефатальных ошибок при работе с существующими доменами. + +# Как это должно выглядеть + +`node-agent` должен быть обычным DEB-пакетом. В пакете само приложение, sysyemd-сервис, конфиг. Бонусом можно доложить консольные утилиты (пока не реализованы): `nodeagent-vmctl` (чтобы напрямую дергать методы виртуалок), `guest-cmd` (обёртка над virsh quest-agent-command). diff --git a/configuration.toml b/configuration.toml new file mode 100644 index 0000000..e1bac53 --- /dev/null +++ b/configuration.toml @@ -0,0 +1,10 @@ +[general] +connect_uri = 'qemu:///system' + +[blockstorage] +local = true +local_path = '/srv/vm-disks' + +[imagestorage] +local = true +local_path = '/srv/vm-images' diff --git a/node_agent/__init__.py b/node_agent/__init__.py new file mode 100644 index 0000000..1d833fd --- /dev/null +++ b/node_agent/__init__.py @@ -0,0 +1 @@ +from .main import NodeAgent diff --git a/node_agent/base.py b/node_agent/base.py new file mode 100644 index 0000000..eafd489 --- /dev/null +++ b/node_agent/base.py @@ -0,0 +1,7 @@ +import libvirt + + +class NodeAgentBase: + def __init__(self, conn: libvirt.virConnect, config: dict): + self.config = config + self.conn = conn diff --git a/node_agent/config.py b/node_agent/config.py new file mode 100644 index 0000000..7dab597 --- /dev/null +++ b/node_agent/config.py @@ -0,0 +1,21 @@ +import os +import sys +import pathlib +import tomllib + + +NODEAGENT_CONFIG_FILE = \ + os.getenv('NODEAGENT_CONFIG_FILE') or '/etc/nodeagent/configuration.toml' + + +def load_config(config: pathlib.Path): + try: + with open(config, 'rb') as conf: + return tomllib.load(conf) + except (OSError, ValueError) as readerr: + sys.exit(f'Error: Cannot read configuration file: {readerr}') + except tomllib.TOMLDecodeError as tomlerr: + sys.exit(f'Error: Bad TOML syntax in configuration file: {tomlerr}') + + +config = load_config(pathlib.Path(NODEAGENT_CONFIG_FILE)) diff --git a/node_agent/exceptions.py b/node_agent/exceptions.py new file mode 100644 index 0000000..2fa30fb --- /dev/null +++ b/node_agent/exceptions.py @@ -0,0 +1,34 @@ +class VMNotFound(Exception): + def __init__(self, domain, message='VM not found: {domain}'): + self.domain = domain + self.message = message.format(domain=domain) + super().__init__(self.message) + + +class VMStartError(Exception): + def __init__(self, domain, message='VM start error: {domain}'): + self.domain = domain + self.message = message.format(domain=domain) + super().__init__(self.message) + + +class VMShutdownError(Exception): + def __init__( + self, + domain, + message="VM '{domain}' cannot shutdown, try with hard=True" + ): + self.domain = domain + self.message = message.format(domain=domain) + super().__init__(self.message) + + +class VMRebootError(Exception): + def __init__( + self, + domain, + message="VM '{domain}' reboot, try with hard=True", + ): + self.domain = domain + self.message = message.format(domain=domain) + super().__init__(self.message) diff --git a/node_agent/main.py b/node_agent/main.py new file mode 100644 index 0000000..7a069fe --- /dev/null +++ b/node_agent/main.py @@ -0,0 +1,8 @@ +import libvirt + +from .vm import VirtualMachine + + +class NodeAgent: + def __init__(self, conn: libvirt.virConnect, config: dict): + self.vm = VirtualMachine(conn, config) diff --git a/node_agent/vm.py b/node_agent/vm.py new file mode 100644 index 0000000..d125428 --- /dev/null +++ b/node_agent/vm.py @@ -0,0 +1,120 @@ +import libvirt + +from .base import NodeAgentBase +from .exceptions import ( + VMNotFound, + VMStartError, + VMRebootError, + VMShutdownError, +) + + +class VirtualMachine(NodeAgentBase): + + def _dom(self, domain: str) -> libvirt.virDomain: + """Get virDomain object to manipulate with domain.""" + try: + ret = self.conn.lookupByName(domain) + if ret is not None: + return ret + raise VMNotFound(domain) + except libvirt.libvirtError as err: + raise VMNotFound(err) from err + + def create( + self, + name: str, + volumes: list[dict], + vcpus: int, + vram: int, + image: dict, + cdrom: dict | None = None, + ): + # TODO + pass + + def delete(self, name: str, delete_volumes=False): + pass + + def status(self, name: str) -> str: + """ + Return VM state: 'running', 'shutoff', etc. Ref: + https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState + """ + state = self._dom(name).info()[0] + match state: + case libvirt.VIR_DOMAIN_NOSTATE: + return 'nostate' + case libvirt.VIR_DOMAIN_RUNNING: + return 'running' + case libvirt.VIR_DOMAIN_BLOCKED: + return 'blocked' + case libvirt.VIR_DOMAIN_PAUSED: + return 'paused' + case libvirt.VIR_DOMAIN_SHUTDOWN: + return 'shutdown' + case libvirt.VIR_DOMAIN_SHUTOFF: + return 'shutoff' + case libvirt.VIR_DOMAIN_CRASHED: + return 'crashed' + case libvirt.VIR_DOMAIN_PMSUSPENDED: + return 'pmsuspended' + + def is_running(self, name: str) -> bool: + """Return True if VM is running, else return False.""" + if self._dom(name).isActive() != 1: + return False # inactive (0) or error (-1) + return True + + def start(self, name: str) -> None: + """Start VM.""" + if not self.is_running(name): + ret = self._dom(name).create() + else: + return + if ret != 0: + raise VMStartError(name) + + def shutdown(self, name: str, hard=False) -> None: + """Shutdown VM. Use hard=True to force shutdown.""" + if hard: + # Destroy VM gracefully (no SIGKILL) + ret = self._dom(name).destroyFlags(flags=libvirt.VIR_DOMAIN_DESTROY_GRACEFUL) + else: + # Normal VM shutdown, OS may ignore this. + ret = self._dom(name).shutdown() + if ret != 0: + raise VMShutdownError(name) + + def reboot(self, name: str, hard=False) -> None: + """ + Reboot VM. Use hard=True to force reboot. With forced reboot + VM will shutdown via self.shutdown() (no forced) and started. + """ + if hard: + # Forced "reboot" + self.shutdown(name) + self.start(name) + else: + # Normal reboot. + ret = self._dom(name).reboot() + if ret != 0: + raise VMRebootError(name) + + def vcpu_set(self, name: str, count: int): + pass + + def vram_set(self, name: str, count: int): + pass + + def ssh_keys_list(self, name: str, user: str): + pass + + def ssh_keys_add(self, name: str, user: str): + pass + + def ssh_keys_remove(self, name: str, user: str): + pass + + def set_user_password(self, name: str, user: str): + pass diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..12010b3 --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,15 @@ +[tool.poetry] +name = "node-agent" +version = "0.1.0" +description = "Node Agent" +authors = ["ge "] +readme = "README.md" +packages = [{include = "node_agent"}] + +[tool.poetry.dependencies] +python = "^3.11" +libvirt-python = "^9.4.0" # 9.0.0 on Debian 12 + +[build-system] +requires = ["poetry-core"] +build-backend = "poetry.core.masonry.api" diff --git a/test.py b/test.py new file mode 100644 index 0000000..fb4e086 --- /dev/null +++ b/test.py @@ -0,0 +1,16 @@ +import libvirt + +from node_agent import NodeAgent +from node_agent.config import config + + +try: + conn = libvirt.open(config['general']['connect_uri']) +except libvirt.libvirtError as err: + sys.exit('Failed to open connection to the hypervisor: %s' % err) + + +node_agent = NodeAgent(conn, config) +s = node_agent.vm.status('debian12') +print(s) +conn.close()