From 42ad91fa83679b0607e67b3f3cffefa220c0386e Mon Sep 17 00:00:00 2001 From: ge Date: Fri, 28 Jul 2023 01:01:32 +0300 Subject: [PATCH] various improvements --- .gitignore | 2 + README.md | 77 ++++++++++-- config.toml | 13 +- node_agent/config.py | 7 +- node_agent/main.py | 4 +- node_agent/utils/vmctl.py | 6 +- node_agent/utils/vmexec.py | 15 ++- node_agent/vm/ga.py | 14 +-- node_agent/vm/main.py | 86 ++++++++----- node_agent/xml.py | 247 ++++++++++++++++++++++++------------- 10 files changed, 322 insertions(+), 149 deletions(-) diff --git a/.gitignore b/.gitignore index 7672fdd..0bc0484 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,5 @@ __pycache__/ *.pyc *~ domain.xml +na +dist/ diff --git a/README.md b/README.md index d2226b6..93540d2 100644 --- a/README.md +++ b/README.md @@ -6,11 +6,11 @@ `node-agent` должен стать обычным DEB-пакетом. Вместе с самим приложением пойдут вспомагательные утилиты: -- **na-vmctl** "Своя" версия virsh, которая дёргает код из Node Agent. Базовые операции с VM и также установка и миграция ВМ. Реализована частично. -- **na-vmexec**. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна. Реализована целиком. -- **na-volctl**. Предполагается здесь оставить всю работу с дисками. Не реализовано. +- `na-vmctl` virsh на минималках, который дёргает код из Node Agent. Выполняет базовые операции с VM, установку и миграцию и т.п. Реализована частично. +- `na-vmexec`. Обёртка для вызова QEMU guest agent на машинах, больше нчего уметь не должна. Реализована целиком. +- `na-volctl`. Предполагается здесь оставить всю работу с дисками. Не реализована. -Этими утилитами Нет цели заменять virsh, нужно реализовать только специфичные для Node Agent вещи. +Этими утилитами нет цели заменять virsh, бцдет реализован только специфичный для Node Agent функционал. Зависимости (версии из APT репозитория Debian 12): @@ -18,6 +18,8 @@ - `python3-docopt` 0.6.2 - `python3-libvirt` 9.0.0 (актуальная новее) +Минимальная поддерживаемая версия Python — `3.11`, потому, что можем. + # Классы Весь пакет разбит на модули, а основной функционал на классы. @@ -49,25 +51,78 @@ with LibvirtSession() as session: ## `VirtualMachine` -Класс для базового управления виртуалкой. В конструктор принимает объект LibvirtSession, который в себе содержит объект virConnect и конфиг в виде словаря. +Класс для базового управления виртуалкой. В конструктор принимает объект LibvirtSession и создаёт объект `virDomain`. ## `QemuAgent` -Класс для работы с агентом на гостях. Его можно считать законченным. Он умеет: +Класс для работы с агентом на гостях. Инициализируется аналогично `VirtualMachine`. Его можно считать законченным. Он умеет: - Выполнять шелл команды через метод `shellexec()`. -- Выполнять команды через `execute()`. +- Выполнять любые команды QEMU через `execute()`. -Внутри также способен: +Также способен: - Поллить выполнение команды. То есть можно дождаться вывода долгой команды. -- Декодирует base64 вывод STDERR и STDOUT если надо. -- Принимать STDIN +- Декодировать base64 вывод STDERR и STDOUT если надо. +- Отправлять данные на STDIN. + +## `XMLConstructor` + +Класс для генерации XML конфигов для либвирта и редактирования XML. Пока умеет очень мало и требует перепиливания. Возможно стоит разбить его на несколько классов. Пример работы с ним: + +```python +from node_agent.xml import XMLConstructor + +domain_xml = XMLConstructor() +domain_xml.gen_domain_xml( + name='13', + title='', + vcpus=2, + cpu_vendor='Intel', + cpu_model='Broadwell', + memory=2048, + volume='/srv/vm-volumes/ef0bcd68-02c2-4f31-ae96-14d2bda5a97b.qcow2', +) +tags_meta = { + 'name': 'tags', + 'children': [ + {'name': 'god_mode'}, + {'name': 'service'} + ] +} +domain_xml.add_meta(tags_meta, namespace='http://half-it-stack.org/xmlns/tags-meta', nsprefix='tags') +print(domain_xml.to_string()) +``` + +В итоге должен получиться какой-то конфиг для ВМ. + +Имеет метод `construct_xml()`, который позволяет привести словарь Python в XML элемент (обхект `lxml.etree.Element`). Пример: + +```python +>>> from lxml.etree import tostring +>>> from na.xml import XMLConstructor +>>> xml = XMLConstructor() +>>> tag = { +... 'name': 'mytag', +... 'values': { +... 'firstname': 'John', +... 'lastname': 'Doe' +... }, +... 'text': 'Hello!', +... 'children': [{'name': 'okay'}] +... } +>>> element = xml.construct_xml(tag) +>>> print(tostring(element).decode()) +'Hello!' +>>> +``` + +Функция рекурсивная, так что теоретически можно положить бесконечное число вложенных элементов в `children`. С аргументами `namespace` и `nsprefix` будет сгенерирован XML с неймспейсом, Ваш кэп. # TODO - [ ] Установка ВМ - - [ ] Конструктор XML + - [x] Конструктор XML (базовый) - [ ] Управление дисками - [ ] Удаление ВМ - [ ] Изменение CPU diff --git a/config.toml b/config.toml index b6755fb..b6ec88f 100644 --- a/config.toml +++ b/config.toml @@ -1,4 +1,7 @@ [general] +# Наверное стоит создавать локи в виде файлов во время операций с ВМ +# /var/node-agent/locks/vms/{vm} +locks_dir = '/var/node-agent/locks' [libvirt] uri = 'qemu:///system' @@ -20,5 +23,13 @@ name = 'hdd' enabled = true path = '/srv/vm-volumes/hdd' -[vm.images] +[vms.defaults] +# Какие-то значения по-умолчанию, используемые при создании/работе с ВМ +# Эти параметры также будут аффектить на CLI утилиты +autostart = true # ставить виртуалки в автостарт после установки +start = true # запускать ВМ после установки +cpu_vendor = 'Intel' +cpu_model = 'Broadwell' + +[vms.images] path = '/srv/vm-images' diff --git a/node_agent/config.py b/node_agent/config.py index 68e1faf..aa7b9f3 100644 --- a/node_agent/config.py +++ b/node_agent/config.py @@ -17,13 +17,14 @@ class ConfigLoader(UserDict): file = NODEAGENT_CONFIG_FILE or NODEAGENT_DEFAULT_CONFIG_FILE self.file = Path(file) self.data = self._load() + # todo: load deafult configuration def _load(self): try: with open(self.file, 'rb') as config: return tomllib.load(config) - # todo: schema validation + # todo: config schema validation except (OSError, ValueError) as readerr: - raise ConfigLoadError('Cannot read config file: %s: %s', (self.file, readerr)) from readerr + raise ConfigLoadError(f'Cannot read config file: {self.file}: {readerr}') from readerr except tomllib.TOMLDecodeError as tomlerr: - raise ConfigLoadError('Bad TOML syntax in config file: %s: %s', (self.file, tomlerr)) from tomlerr + raise ConfigLoadError(f'Bad TOML syntax in config file: {self.file}: {tomlerr}') from tomlerr diff --git a/node_agent/main.py b/node_agent/main.py index ba4adc0..9ff169e 100644 --- a/node_agent/main.py +++ b/node_agent/main.py @@ -23,8 +23,8 @@ class LibvirtSession(AbstractContextManager): return libvirt.open(connection_uri) except libvirt.libvirtError as err: raise LibvirtSessionError( - 'Failed to open connection to the hypervisor: %s' % err - ) + f'Failed to open connection to the hypervisor: {err}' + ) from err def close(self) -> None: self.session.close() diff --git a/node_agent/utils/vmctl.py b/node_agent/utils/vmctl.py index 7db4529..c949070 100644 --- a/node_agent/utils/vmctl.py +++ b/node_agent/utils/vmctl.py @@ -17,6 +17,7 @@ import sys import pathlib import logging +import libvirt from docopt import docopt sys.path.append('/home/ge/Code/node-agent') @@ -38,13 +39,14 @@ def cli(): args = docopt(__doc__) config = pathlib.Path(args['--config']) or None loglvl = args['--loglvl'].upper() + machine = args[''] if loglvl in levels: logging.basicConfig(level=levels[loglvl]) with LibvirtSession(config) as session: try: - vm = VirtualMachine(session, args['']) + vm = VirtualMachine(session, machine) if args['status']: print(vm.status) if args['is-running']: @@ -58,7 +60,7 @@ def cli(): if args['shutdown']: vm.shutdown(force=args['--force'], sigkill=args['sigkill']) except VMNotFound as nferr: - sys.exit(f'{Color.RED}VM {args[""]} not found.{Color.NONE}') + sys.exit(f'{Color.RED}VM {machine} not found.{Color.NONE}') except VMError as vmerr: sys.exit(f'{Color.RED}{vmerr}{Color.NONE}') diff --git a/node_agent/utils/vmexec.py b/node_agent/utils/vmexec.py index c4c4a88..2a0a339 100644 --- a/node_agent/utils/vmexec.py +++ b/node_agent/utils/vmexec.py @@ -35,6 +35,7 @@ def cli(): args = docopt(__doc__) config = pathlib.Path(args['--config']) or None loglvl = args['--loglvl'].upper() + machine = args[''] if loglvl in levels: logging.basicConfig(level=levels[loglvl]) @@ -44,7 +45,7 @@ def cli(): cmd = args[''] try: - ga = QemuAgent(session, args['']) + ga = QemuAgent(session, machine) exited, exitcode, stdout, stderr = ga.shellexec( cmd, executable=shell, @@ -63,14 +64,15 @@ def cli(): sys.exit(errmsg) except VMNotFound as err: sys.exit( - f'{Color.RED}VM {args[""]} not found.{Color.NONE}' + f'{Color.RED}VM {machine} not found.{Color.NONE}' ) if not exited: print( Color.YELLOW +'[NOTE: command may still running]' - + Color.NONE + + Color.NONE, + file=sys.stderr ) else: if exitcode == 0: @@ -80,13 +82,14 @@ def cli(): print( exitcolor + f'[command exited with exit code {exitcode}]' - + Color.NONE + + Color.NONE, + file=sys.stderr ) if stderr: - print(Color.RED + stderr.strip() + Color.NONE) + print(Color.RED + stderr.strip() + Color.NONE, file=sys.stderr) if stdout: - print(Color.GREEN + stdout.strip() + Color.NONE) + print(Color.GREEN + stdout.strip() + Color.NONE, file=sys.stdout) if __name__ == '__main__': cli() diff --git a/node_agent/vm/ga.py b/node_agent/vm/ga.py index 6c3f65d..a7ed09e 100644 --- a/node_agent/vm/ga.py +++ b/node_agent/vm/ga.py @@ -14,8 +14,8 @@ from .base import VMBase logger = logging.getLogger(__name__) -DEFAULT_WAIT_TIMEOUT = 60 # seconds -POLL_INTERVAL = 0.3 +QEMU_TIMEOUT = 60 # seconds +POLL_INTERVAL = 0.3 # also seconds class QemuAgent(VMBase): @@ -33,7 +33,7 @@ class QemuAgent(VMBase): _get_cmd_result() Intended for long-time commands. This function loops and every POLL_INTERVAL calls 'guest-exec-status' for specified guest PID. - Polling ends on command exited or on timeout. + Polling ends if command exited or on timeout. _return_tuple() This method transforms JSON command output to tuple and decode base64 encoded strings optionally. @@ -46,7 +46,7 @@ class QemuAgent(VMBase): flags: int | None = None ): super().__init__(session, name) - self.timeout = timeout or DEFAULT_WAIT_TIMEOUT # timeout for guest agent + self.timeout = timeout or QEMU_TIMEOUT # timeout for guest agent self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT def execute( @@ -56,7 +56,7 @@ class QemuAgent(VMBase): capture_output: bool = False, decode_output: bool = False, wait: bool = True, - timeout: int = DEFAULT_WAIT_TIMEOUT, + timeout: int = QEMU_TIMEOUT, ): """ Execute command on guest and return output if capture_output is True. @@ -99,7 +99,7 @@ class QemuAgent(VMBase): capture_output: bool = False, decode_output: bool = False, wait: bool = True, - timeout: int = DEFAULT_WAIT_TIMEOUT, + timeout: int = QEMU_TIMEOUT, ): """ Execute command on guest with selected shell. /bin/sh by default. @@ -139,7 +139,7 @@ class QemuAgent(VMBase): pid: int, decode_output: bool = False, wait: bool = True, - timeout: int = DEFAULT_WAIT_TIMEOUT, + timeout: int = QEMU_TIMEOUT, ): """Get executed command result. See GuestAgent.execute() for info.""" exited = exitcode = stdout = stderr = None diff --git a/node_agent/vm/main.py b/node_agent/vm/main.py index 4a1d119..5109915 100644 --- a/node_agent/vm/main.py +++ b/node_agent/vm/main.py @@ -13,7 +13,7 @@ class VirtualMachine(VMBase): @property def name(self): - return self.domain.name() + return self.domname @property def status(self) -> str: @@ -21,7 +21,12 @@ class VirtualMachine(VMBase): Return VM state: 'running', 'shutoff', etc. Reference: https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState """ - state = self.domain.info()[0] + 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.domname}: {err}') from err match state: case libvirt.VIR_DOMAIN_NOSTATE: return 'nostate' @@ -48,6 +53,16 @@ class VirtualMachine(VMBase): 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.domname}: {err}') from err + def start(self) -> None: """Start defined VM.""" logger.info('Starting VM: vm=%s', self.domname) @@ -57,9 +72,7 @@ class VirtualMachine(VMBase): try: ret = self.domain.create() except libvirt.libvirtError as err: - raise VMError(err) from err - if ret != 0: - raise VMError('Cannot start VM: vm=%s exit_code=%s', self.domname, ret) + raise VMError(f'Cannot start vm={self.domname} return_code={ret}: {err}') from err def shutdown(self, force=False, sigkill=False) -> None: """ @@ -71,41 +84,56 @@ class VirtualMachine(VMBase): flags = libvirt.VIR_DOMAIN_DESTROY_DEFAULT else: flags = libvirt.VIR_DOMAIN_DESTROY_GRACEFUL - if force: - ret = self.domain.destroyFlags(flags=flags) - else: - # Normal VM shutdown via ACPI signal, OS may ignore this. - ret = self.domain.shutdown() - if ret != 0: + try: + if force: + self.domain.destroyFlags(flags=flags) + else: + # Normal VM shutdown via ACPI signal, OS may ignore this. + self.domain.shutdown() + except libvirt.libvirtError as err: raise VMError( - f'Cannot shutdown VM, try force or sigkill: %s', self.domname - ) + f'Cannot shutdown vm={self.domname} ' + f'force={force} sigkill={sigkill}: {err}' + ) from err def reset(self): """ - Copypaste from libvirt doc:: + 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. + 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. + Note that there is a risk of data loss caused by reset without any + guest OS shutdown. """ - ret = self.domian.reset() - if ret != 0: - raise VMError('Cannot reset VM: %s', self.domname) + try: + self.domian.reset() + except libvirt.libvirtError as err: + raise VMError(f'Cannot reset vm={self.domname}: {err}') from err def reboot(self) -> None: """Send ACPI signal to guest OS to reboot. OS may ignore this.""" - ret = self.domain.reboot() - if ret != 0: - raise VMError('Cannot reboot: %s', self.domname) + try: + self.domain.reboot() + except libvirt.libvirtError as err: + raise VMError(f'Cannot reboot vm={self.domname}: {err}') from err - def set_autostart(self) -> None: - ret = self.domain.autostart() - if ret != 0: - raise VMError('Cannot set : %s', self.domname) + def autostart(self, enabled: bool) -> None: + """ + Configure VM to be automatically started when the host machine boots. + """ + if enabled: + 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.domname} ' + f'autostart={autostart_flag}: {err}' + ) from err def vcpu_set(self, count: int): pass diff --git a/node_agent/xml.py b/node_agent/xml.py index bf354e1..af4e267 100644 --- a/node_agent/xml.py +++ b/node_agent/xml.py @@ -1,91 +1,79 @@ -import pathlib +from pathlib import Path -from lxml import etree +from lxml.etree import Element, SubElement, QName, tostring from lxml.builder import E -class NewXML: - def __init__( +class XMLConstructor: + """ + The XML constructor. This class builds XML configs for libvirtd. + Features: + - Generate basic virtual machine XML. See gen_domain_xml() + - Generate virtual disk XML. See gen_volume_xml() + - Add arbitrary metadata to XML from special structured dict + """ + + def __init__(self, xml: str | None = None): + self.xml_string = xml + self.xml = None + + @property + def domain_xml(self): + return self.xml + + def gen_domain_xml( self, name: str, title: str, - memory: int, vcpus: int, cpu_vendor: str, cpu_model: str, - volume_path: str, - - desc: str | None = None, - show_boot_menu: bool = False, - ): + memory: int, + volume: Path, + desc: str = "" + ) -> None: """ - Initialise basic XML using lxml E-Factory. Ref: - - - https://lxml.de/tutorial.html#the-e-factory - - https://libvirt.org/formatdomain.html + Generate default domain XML configuration for virtual machines. + See https://lxml.de/tutorial.html#the-e-factory for details. """ - DOMAIN = E.domain - NAME = E.name - TITLE = E.title - DESCRIPTION = E.description - METADATA = E.metadata - MEMORY = E.memory - CURRENTMEMORY = E.currentMemory - VCPU = E.vcpu - OS = E.os - OS_TYPE = E.type - OS_BOOT = E.boot - FEATURES = E.features - ACPI = E.acpi - APIC = E.apic - CPU = E.cpu - CPU_VENDOR = E.vendor - CPU_MODEL = E.model - ON_POWEROFF = E.on_poweroff - ON_REBOOT = E.on_reboot - ON_CRASH = E.on_crash - DEVICES = E.devices - EMULATOR = E.emulator - DISK = E.disk - DISK_DRIVER = E.driver - DISK_SOURCE = E.source - DISK_TARGET = E.target - INTERFACE = E.interface - GRAPHICS = E.graphics - - self.domain = DOMAIN( - NAME(name), - TITLE(title), - DESCRIPTION(desc or ""), - METADATA(), - MEMORY(str(memory), unit='MB'), - CURRENTMEMORY(str(memory), unit='MB'), - VCPU(str(vcpus), placement='static'), - OS( - OS_TYPE('hvm', arch='x86_64'), - OS_BOOT(dev='cdrom'), - OS_BOOT(dev='hd'), + self.xml = E.domain( + E.name(name), + E.title(title), + E.description(desc), + E.metadata(), + E.memory(str(memory), unit='MB'), + E.currentMemory(str(memory), unit='MB'), + E.vcpu(str(vcpus), placement='static'), + E.os( + E.type('hvm', arch='x86_64'), + E.boot(dev='cdrom'), + E.boot(dev='hd'), ), - FEATURES( - ACPI(), - APIC(), + E.features( + E.acpi(), + E.apic(), ), - CPU( - CPU_VENDOR(cpu_vendor), - CPU_MODEL(cpu_model, fallback='forbid'), + E.cpu( + E.vendor(cpu_vendor), + E.model(cpu_model, fallback='forbid'), + E.topology(sockets='1', dies='1', cores=str(vcpus), threads='1'), mode='custom', match='exact', check='partial', ), - ON_POWEROFF('destroy'), - ON_REBOOT('restart'), - ON_CRASH('restart'), - DEVICES( - EMULATOR('/usr/bin/qemu-system-x86_64'), - DISK( - DISK_DRIVER(name='qemu', type='qcow2', cache='writethrough'), - DISK_SOURCE(file=volume_path), - DISK_TARGET(dev='vda', bus='virtio'), + E.on_poweroff('destroy'), + E.on_reboot('restart'), + E.on_crash('restart'), + E.pm( + E('suspend-to-mem', enabled='no'), + E('suspend-to-disk', enabled='no'), + ), + E.devices( + E.emulator('/usr/bin/qemu-system-x86_64'), + E.disk( + E.driver(name='qemu', type='qcow2', cache='writethrough'), + E.source(file=volume), + E.target(dev='vda', bus='virtio'), type='file', device='disk', ), @@ -93,23 +81,106 @@ class NewXML: type='kvm', ) - def add_volume(self, options: dict, params: dict): - """Add disk device to domain.""" - DISK = E.disk - DISK_DRIVER = E.driver - DISK_SOURCE = E.source - DISK_TARGET = E.target + def gen_volume_xml( + self, + device_name: str, + file: Path, + bus: str = 'virtio', + cache: str = 'writethrough', + disktype: str = 'file', + ): + return E.disk( + E.driver(name='qemu', type='qcow2', cache=cache), + E.source(file=file), + E.target(dev=device_name, bus=bus), + type=disktype, + device='disk' + ) -x = NewXML( - name='1', - title='first', - memory=2048, - vcpus=4, - cpu_vendor='Intel', - cpu_model='Broadwell', - volume_path='/srv/vm-volumes/5031077f-f9ea-410b-8d84-ae6e79f8cde0.qcow2', -) + def add_volume(self): + raise NotImplementedError() -# x.add_volume() -# print(x.domain) -print(etree.tostring(x.domain, pretty_print=True).decode().strip()) + def add_meta(self, data: dict, namespace: str, nsprefix: str) -> None: + """ + Add metadata to domain. See: + https://libvirt.org/formatdomain.html#general-metadata + """ + metadata = metadata_old = self.xml.xpath('/domain/metadata')[0] + metadata.append( + self.construct_xml( + data, + namespace=namespace, + nsprefix=nsprefix, + ) + ) + self.xml.replace(metadata_old, metadata) + + def remove_meta(self, namespace: str): + """Remove metadata by namespace.""" + raise NotImplementedError() + + 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': 'devices', # 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 to_string(self): + return tostring( + self.xml, pretty_print=True, encoding='utf-8' + ).decode().strip()