diff --git a/.gitignore b/.gitignore index 0bc0484..2d25954 100644 --- a/.gitignore +++ b/.gitignore @@ -2,5 +2,8 @@ __pycache__/ *.pyc *~ domain.xml +domgen.py na dist/ +P@ssw0rd +*.todo diff --git a/Makefile b/Makefile index 6d657d4..23fdf9e 100644 --- a/Makefile +++ b/Makefile @@ -1,3 +1,5 @@ +SRC = na/ + all: build build: @@ -6,3 +8,10 @@ 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) + +lint: + pylint $(SRC) diff --git a/README.md b/README.md index 93540d2..c3afecd 100644 --- a/README.md +++ b/README.md @@ -123,6 +123,8 @@ print(domain_xml.to_string()) - [ ] Установка ВМ - [x] Конструктор XML (базовый) + - [ ] Метод создания дисков + - [ ] Дефайн, запуск и автостарт ВМ - [ ] Управление дисками - [ ] Удаление ВМ - [ ] Изменение CPU @@ -130,12 +132,12 @@ print(domain_xml.to_string()) - [ ] Миграция ВМ между нодами - [x] Работа с qemu-ga - [x] Управление питанием -- [ ] Вкл/выкл автостарт ВМ +- [x] Вкл/выкл автостарт ВМ - [ ] Статистика потребления ресурсов - [ ] Получение инфомрации из/о ВМ - [ ] SSH-ключи - [ ] Сеть -- [ ] ??? +- [ ] Создание снапшотов # Заметки diff --git a/node_agent/__init__.py b/node_agent/__init__.py index 8aae7b9..c20e163 100644 --- a/node_agent/__init__.py +++ b/node_agent/__init__.py @@ -1,3 +1,3 @@ -from .main import LibvirtSession from .config import ConfigLoader +from .session import LibvirtSession from .vm import * diff --git a/node_agent/utils/vmctl.py b/node_agent/cli/vmctl.py similarity index 98% rename from node_agent/utils/vmctl.py rename to node_agent/cli/vmctl.py index b1053cd..09b99de 100644 --- a/node_agent/utils/vmctl.py +++ b/node_agent/cli/vmctl.py @@ -13,14 +13,14 @@ Options: -9, --sigkill Send SIGKILL to QEMU process. Not affects without --force """ -import sys -import pathlib import logging +import pathlib +import sys import libvirt from docopt import docopt -from ..main import LibvirtSession +from ..session import LibvirtSession from ..vm import VirtualMachine, VMError, VMNotFound diff --git a/node_agent/utils/vmexec.py b/node_agent/cli/vmexec.py similarity index 64% rename from node_agent/utils/vmexec.py rename to node_agent/cli/vmexec.py index 9de4c62..f509145 100644 --- a/node_agent/utils/vmexec.py +++ b/node_agent/cli/vmexec.py @@ -10,13 +10,13 @@ Options: -t, --timeout QEMU timeout in seconds to stop polling command status [default: 60] """ -import sys -import pathlib import logging +import pathlib +import sys from docopt import docopt -from ..main import LibvirtSession +from ..session import LibvirtSession from ..vm import QemuAgent, QemuAgentError, VMNotFound @@ -30,6 +30,8 @@ class Color: YELLOW = '\033[33m' NONE = '\033[0m' +# TODO: Add STDIN support e.g.: cat something.sh | na-vmexec vmname bash + def cli(): args = docopt(__doc__) @@ -50,44 +52,28 @@ def cli(): try: ga = QemuAgent(session, machine) exited, exitcode, stdout, stderr = ga.shellexec( - cmd, - executable=shell, - capture_output=True, - decode_output=True, - timeout=int(args['--timeout']), - ) + cmd, executable=shell, capture_output=True, decode_output=True, + timeout=int(args['--timeout'])) except QemuAgentError as qemuerr: - errmsg = f'{Color.RED}{err}{Color.NONE}' - if str(err).startswith('Polling command pid='): - errmsg = ( - errmsg + Color.YELLOW - + '\n[NOTE: command may still running]' - + Color.NONE - ) + errmsg = f'{Color.RED}{qemuerr}{Color.NONE}' + if str(qemuerr).startswith('Polling command pid='): + errmsg = (errmsg + Color.YELLOW + + '\n[NOTE: command may still running]' + Color.NONE) sys.exit(errmsg) except VMNotFound as err: - sys.exit( - f'{Color.RED}VM {machine} not found.{Color.NONE}' - ) + sys.exit(f'{Color.RED}VM {machine} not found{Color.NONE}') if not exited: - print( - Color.YELLOW - +'[NOTE: command may still running]' - + Color.NONE, - file=sys.stderr - ) + print(Color.YELLOW + '[NOTE: command may still running]' + Color.NONE, + file=sys.stderr) else: if exitcode == 0: exitcolor = Color.GREEN else: exitcolor = Color.RED - print( - exitcolor - + f'[command exited with exit code {exitcode}]' - + Color.NONE, - file=sys.stderr - ) + print(exitcolor + f'[command exited with exit code {exitcode}]' + + Color.NONE, + file=sys.stderr) if stderr: print(Color.RED + stderr.strip() + Color.NONE, file=sys.stderr) @@ -95,5 +81,6 @@ def cli(): print(Color.GREEN + stdout.strip() + Color.NONE, file=sys.stdout) sys.exit(exitcode) + if __name__ == '__main__': cli() diff --git a/node_agent/utils/volctl.py b/node_agent/cli/volctl.py similarity index 100% rename from node_agent/utils/volctl.py rename to node_agent/cli/volctl.py diff --git a/node_agent/config.py b/node_agent/config.py index aad7de8..16e48bb 100644 --- a/node_agent/config.py +++ b/node_agent/config.py @@ -1,7 +1,7 @@ import os import tomllib -from pathlib import Path from collections import UserDict +from pathlib import Path NODEAGENT_CONFIG_FILE = os.getenv('NODEAGENT_CONFIG_FILE') @@ -9,15 +9,16 @@ NODEAGENT_DEFAULT_CONFIG_FILE = '/etc/node-agent/config.toml' class ConfigLoadError(Exception): - """Bad config file syntax, unreachable file or bad data.""" + """Bad config file syntax, unreachable file or bad config schema.""" 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) - self.data = self._load() + super().__init__(self._load()) # todo: load deafult configuration def _load(self): @@ -26,6 +27,12 @@ class ConfigLoader(UserDict): return tomllib.load(config) # todo: config schema validation except tomllib.TOMLDecodeError as tomlerr: - raise ConfigLoadError(f'Bad TOML syntax in config file: {self.file}: {tomlerr}') from tomlerr + raise ConfigLoadError( + f'Bad TOML syntax in config file: {self.file}: {tomlerr}' + ) from tomlerr except (OSError, ValueError) as readerr: - raise ConfigLoadError(f'Cannot read config file: {self.file}: {readerr}') from readerr + raise ConfigLoadError( + f'Cannot read config file: {self.file}: {readerr}') from readerr + + def reload(self): + self.data = self._load() diff --git a/node_agent/main.py b/node_agent/session.py similarity index 90% rename from node_agent/main.py rename to node_agent/session.py index 798976c..6d8ac5f 100644 --- a/node_agent/main.py +++ b/node_agent/session.py @@ -1,5 +1,5 @@ -from pathlib import Path from contextlib import AbstractContextManager +from pathlib import Path import libvirt @@ -7,10 +7,11 @@ from .config import ConfigLoader class LibvirtSessionError(Exception): - """Something went wrong while connecting to libvirt.""" + """Something went wrong while connecting to libvirtd.""" class LibvirtSession(AbstractContextManager): + def __init__(self, config: Path | None = None): self.config = ConfigLoader(config) self.session = self._connect(self.config['libvirt']['uri']) @@ -26,8 +27,7 @@ class LibvirtSession(AbstractContextManager): return libvirt.open(connection_uri) except libvirt.libvirtError as err: raise LibvirtSessionError( - f'Failed to open connection to the hypervisor: {err}' - ) from 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/__init__.py b/node_agent/utils/__init__.py new file mode 100644 index 0000000..58d97ac --- /dev/null +++ b/node_agent/utils/__init__.py @@ -0,0 +1 @@ +from .mac import * diff --git a/node_agent/utils/mac.py b/node_agent/utils/mac.py new file mode 100644 index 0000000..d8e5455 --- /dev/null +++ b/node_agent/utils/mac.py @@ -0,0 +1,16 @@ +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)) + + +def unique_mac() -> str: + """Return non-conflicting MAC address.""" + # todo: see virtinst.DeviceInterface.generate_mac + raise NotImplementedError() diff --git a/node_agent/xml.py b/node_agent/utils/xml.py similarity index 59% rename from node_agent/xml.py rename to node_agent/utils/xml.py index af4e267..3e9d4e1 100644 --- a/node_agent/xml.py +++ b/node_agent/utils/xml.py @@ -1,7 +1,20 @@ from pathlib import Path -from lxml.etree import Element, SubElement, QName, tostring from lxml.builder import E +from lxml.etree import Element, QName, SubElement, tostring + +from .mac import random_mac + + +XPATH_DOMAIN_NAME = '/domain/name' +XPATH_DOMAIN_TITLE = '/domain/title' +XPATH_DOMAIN_DESCRIPTION = '/domain/description' +XPATH_DOMAIN_METADATA = '/domain/metadata' +XPATH_DOMAIN_MEMORY = '/domain/memory' +XPATH_DOMAIN_CURRENT_MEMORY = '/domain/currentMemory' +XPATH_DOMAIN_VCPU = '/domain/vcpu' +XPATH_DOMAIN_OS = '/domian/os' +XPATH_DOMAIN_CPU = '/domain/cpu' class XMLConstructor: @@ -21,17 +34,16 @@ class XMLConstructor: def domain_xml(self): return self.xml - def gen_domain_xml( - self, - name: str, - title: str, - vcpus: int, - cpu_vendor: str, - cpu_model: str, - memory: int, - volume: Path, - desc: str = "" - ) -> None: + def gen_domain_xml(self, + name: str, + title: str, + vcpus: int, + vcpu_vendor: str, + vcpu_model: str, + memory: int, + volume: Path, + vcpu_features: dict | None = None, + desc: str = "") -> None: """ Generate default domain XML configuration for virtual machines. See https://lxml.de/tutorial.html#the-e-factory for details. @@ -54,9 +66,10 @@ class XMLConstructor: E.apic(), ), E.cpu( - E.vendor(cpu_vendor), - E.model(cpu_model, fallback='forbid'), - E.topology(sockets='1', dies='1', cores=str(vcpus), threads='1'), + E.vendor(vcpu_vendor), + E.model(vcpu_model, fallback='forbid'), + E.topology(sockets='1', dies='1', cores=str(vcpus), + threads='1'), mode='custom', match='exact', check='partial', @@ -77,25 +90,35 @@ class XMLConstructor: type='file', device='disk', ), + E.interface( + E.source(network='default'), + E.mac(address=random_mac()), + type='network', + ), + E.graphics( + E.listen(type='address'), + type='vnc', port='-1', autoport='yes' + ), + E.video( + E.model(type='vga', vram='16384', heads='1', primary='yes'), + E.address(type='pci', domain='0x0000', bus='0x00', + slot='0x02', function='0x0'), + ), ), type='kvm', ) - 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' - ) + 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') def add_volume(self): raise NotImplementedError() @@ -111,21 +134,18 @@ class XMLConstructor: 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: + 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:: @@ -148,18 +168,13 @@ class XMLConstructor: # Create element if root is None: if use_ns: - element = Element( - QName(namespace, tag['name']), - nsmap={nsprefix: namespace}, - ) + 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']), - ) + element = SubElement(root, QName(namespace, tag['name'])) else: element = SubElement(root, tag['name']) # Fill up element with content @@ -171,16 +186,12 @@ class XMLConstructor: if 'children' in tag.keys(): for child in tag['children']: element.append( - self.construct_xml( - child, - namespace=namespace, - nsprefix=nsprefix, - root=element, - ) - ) + 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() + return (tostring(self.xml, pretty_print=True, + encoding='utf-8').decode().strip()) diff --git a/node_agent/vm/__init__.py b/node_agent/vm/__init__.py index 63731fb..c190a25 100644 --- a/node_agent/vm/__init__.py +++ b/node_agent/vm/__init__.py @@ -1,3 +1,3 @@ -from .main import VirtualMachine -from .ga import QemuAgent, QemuAgentError from .exceptions import * +from .ga import QemuAgent +from .main import VirtualMachine diff --git a/node_agent/vm/base.py b/node_agent/vm/base.py index 81f1126..5338d61 100644 --- a/node_agent/vm/base.py +++ b/node_agent/vm/base.py @@ -1,11 +1,11 @@ import libvirt -from ..main import LibvirtSession -from .exceptions import VMNotFound +from .exceptions import VMError, VMNotFound class VirtualMachineBase: - def __init__(self, session: LibvirtSession, name: str): + + def __init__(self, session: 'LibvirtSession', name: str): self.domname = name self.session = session.session # virConnect object self.config = session.config # ConfigLoader object @@ -19,4 +19,4 @@ class VirtualMachineBase: return domain raise VMNotFound(name) except libvirt.libvirtError as err: - raise VMNotFound(err) from err + raise VMError(err) from err diff --git a/node_agent/vm/exceptions.py b/node_agent/vm/exceptions.py index b88b8be..e99e480 100644 --- a/node_agent/vm/exceptions.py +++ b/node_agent/vm/exceptions.py @@ -7,6 +7,7 @@ class VMError(Exception): class VMNotFound(Exception): + def __init__(self, domain, message='VM not found: {domain}'): self.domain = domain self.message = message.format(domain=domain) diff --git a/node_agent/vm/ga.py b/node_agent/vm/ga.py index a262c63..52b31dd 100644 --- a/node_agent/vm/ga.py +++ b/node_agent/vm/ga.py @@ -1,19 +1,17 @@ import json import logging -from time import time, sleep -from base64 import standard_b64encode, b64decode +from base64 import b64decode, standard_b64encode +from time import sleep, time import libvirt import libvirt_qemu -from ..main import LibvirtSession from .base import VirtualMachineBase from .exceptions import QemuAgentError logger = logging.getLogger(__name__) - QEMU_TIMEOUT = 60 # seconds POLL_INTERVAL = 0.3 # also seconds @@ -28,39 +26,30 @@ class QemuAgent(VirtualMachineBase): shellexec() High-level method for executing shell commands on guest. Command must be passed as string. Wraps execute() method. - _execute() - Just executes QEMU command. Wraps libvirt_qemu.qemuAgentCommand() - _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 if command exited or on timeout. - _return_tuple() - This method transforms JSON command output to tuple and decode - base64 encoded strings optionally. """ def __init__(self, - session: LibvirtSession, - name: str, - timeout: int | None = None, - flags: int | None = None - ): + session: 'LibvirtSession', + name: str, + timeout: int | None = None, + flags: int | None = None): super().__init__(session, name) self.timeout = timeout or QEMU_TIMEOUT # timeout for guest agent self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT - def execute( - self, - command: dict, - stdin: str | None = None, - capture_output: bool = False, - decode_output: bool = False, - wait: bool = True, - timeout: int = QEMU_TIMEOUT, - ): + 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. + 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 + QemuAgentError on `timeout` reached (in seconds). Return values: tuple( exited: bool | None, @@ -68,15 +57,15 @@ class QemuAgent(VirtualMachineBase): stdout: str | None, stderr: str | None ) - stdout and stderr are base64 encoded strings or 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') + stdin.encode('utf-8')).decode('utf-8') # Execute command on guest cmd_out = self._execute(command) @@ -91,19 +80,18 @@ class QemuAgent(VirtualMachineBase): ) 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, - ): + 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 command as string. + Otherwise of execute() this function brings shell command as string. """ cmd = { 'execute': 'guest-exec', @@ -121,7 +109,6 @@ class QemuAgent(VirtualMachineBase): timeout=timeout, ) - def _execute(self, command: dict): logging.debug('Execute command: vm=%s cmd=%s', self.domname, command) try: @@ -135,19 +122,10 @@ class QemuAgent(VirtualMachineBase): raise QemuAgentError(err) from err def _get_cmd_result( - self, - pid: int, - decode_output: bool = False, - wait: bool = True, - timeout: int = QEMU_TIMEOUT, - ): + self, pid: int, decode_output: bool = False, wait: bool = True, + timeout: int = QEMU_TIMEOUT): """Get executed command result. See GuestAgent.execute() for info.""" - exited = exitcode = stdout = stderr = None - - cmd = { - 'execute': 'guest-exec-status', - 'arguments': {'pid': pid}, - } + cmd = {'execute': 'guest-exec-status', 'arguments': {'pid': pid}} if not wait: output = json.loads(self._execute(cmd)) @@ -165,28 +143,23 @@ class QemuAgent(VirtualMachineBase): raise QemuAgentError( f'Polling command pid={pid} took longer than {timeout} seconds.' ) - logger.debug( - 'Polling command pid=%s finished, time taken: %s seconds', - pid, int(time()-start_time) - ) + logger.debug('Polling command pid=%s finished, time taken: %s seconds', + pid, int(time() - start_time)) return self._return_tuple(output, decode=decode_output) - def _return_tuple(self, cmd_output: dict, decode: bool = False): - exited = cmd_output['return']['exited'] - exitcode = cmd_output['return']['exitcode'] + def _return_tuple(self, output: dict, decode: bool = False): + output = output['return'] + exited = output['exited'] + exitcode = output['exitcode'] + stdout = stderr = None - try: - stdout = cmd_output['return']['out-data'] - if decode and stdout: - stdout = b64decode(stdout).decode('utf-8') - except KeyError: - stdout = None + if 'out-data' in output.keys(): + stdout = output['out-data'] + if 'err-data' in output.keys(): + stderr = output['err-data'] - try: - stderr = cmd_output['return']['err-data'] - if decode and stderr: - stderr = b64decode(stderr).decode('utf-8') - except KeyError: - stderr = None + if decode: + stdout = b64decode(stdout).decode('utf-8') if stdout else None + stderr = b64decode(stderr).decode('utf-8') if stderr else None return exited, exitcode, stdout, stderr diff --git a/node_agent/vm/main.py b/node_agent/vm/main.py index 23fc94a..dc473d9 100644 --- a/node_agent/vm/main.py +++ b/node_agent/vm/main.py @@ -26,24 +26,19 @@ class VirtualMachine(VirtualMachineBase): # 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' - 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' + raise VMError( + f'Cannot fetch VM status vm={self.domname}: {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: @@ -61,42 +56,53 @@ class VirtualMachine(VirtualMachineBase): return True return False except libvirt.libvirtError as err: - raise VMError(f'Cannot get autostart status vm={self.domname}: {err}') from 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) if self.is_running: - logger.debug('VM vm=%s is already started, nothing to do', self.domname) + logger.debug('VM vm=%s is already started, nothing to do', + self.domname) return try: self.domain.create() except libvirt.libvirtError as err: raise VMError(f'Cannot start vm={self.domname}: {err}') from err - def shutdown(self, force=False, sigkill=False) -> None: + def shutdown(self, mode: str | None = None) -> None: """ - Send ACPI signal to guest OS to shutdown. OS may ignore this. - Use `force=True` for graceful VM destroy. Add `sigkill=True` - to hard shutdown (may corrupt guest data!). + 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, this option may corrupt guest data! + If mode is not passed use 'NORMAL' mode. """ - if sigkill: - flags = libvirt.VIR_DOMAIN_DESTROY_DEFAULT - else: - flags = libvirt.VIR_DOMAIN_DESTROY_GRACEFUL + MODES = { + '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 mode is None: + mode = 'NORMAL' + if not isinstance(mode, str): + raise ValueError(f'Mode must be a string, not {type(mode)}') + if mode.upper() not in MODES: + raise ValueError(f"Unsupported mode: '{mode}'") try: - if force: - self.domain.destroyFlags(flags=flags) - else: - # Normal VM shutdown via ACPI signal, OS may ignore this. - self.domain.shutdown() + if mode in ['GUEST_AGENT', 'NORMAL']: + self.domain.shutdownFlags(flags=MODES.get(mode)) + elif mode in ['SIGTERM', 'SIGKILL']: + self.domain.destroyFlags(flags=MODES.get(mode)) except libvirt.libvirtError as err: - raise VMError( - f'Cannot shutdown vm={self.domname} ' - f'force={force} sigkill={sigkill}: {err}' - ) from err + raise VMError(f'Cannot shutdown vm={self.domname} with ' + f'mode={mode}: {err}') from err - def reset(self): + def reset(self) -> None: """ Copypaste from libvirt doc: @@ -119,35 +125,33 @@ class VirtualMachine(VirtualMachineBase): except libvirt.libvirtError as err: raise VMError(f'Cannot reboot vm={self.domname}: {err}') from err - def autostart(self, enabled: bool) -> None: + def autostart(self, enable: bool) -> None: """ Configure VM to be automatically started when the host machine boots. """ - if enabled: + 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.domname} ' - f'autostart={autostart_flag}: {err}' - ) from err + raise VMError(f'Cannot set autostart vm={self.domname} ' + f'autostart={autostart_flag}: {err}') from err - def vcpu_set(self, count: int): + def set_vcpus(self, count: int): pass - def vram_set(self, count: int): + def set_ram(self, count: int): pass - def ssh_keys_list(self, user: str): + def list_ssh_keys(self, user: str): pass - def ssh_keys_add(self, user: str): + def set_ssh_keys(self, user: str): pass - def ssh_keys_remove(self, user: str): + def remove_ssh_keys(self, user: str): pass def set_user_password(self, user: str): diff --git a/pyproject.toml b/pyproject.toml index 6cbbe64..813e2be 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -12,9 +12,22 @@ lxml = "^4.9.2" # 4.9.2 on Debian 12 docopt = "^0.6.2" # 0.6.2 on Debian 12 [tool.poetry.scripts] -na-vmctl = "node_agent.utils.vmctl:cli" -na-vmexec = "node_agent.utils.vmexec:cli" +na-vmctl = "node_agent.cli.vmctl:cli" +na-vmexec = "node_agent.cli.vmexec:cli" [build-system] requires = ["poetry-core"] build-backend = "poetry.core.masonry.api" + +[tool.yapf] + +[tool.pylint."MESSAGES CONTROL"] +disable = [ + "invalid-name", + "missing-module-docstring", + "missing-class-docstring", + "missing-function-docstring", + "import-error", + "too-many-arguments", +] +