various updates

This commit is contained in:
ge 2023-08-24 22:36:12 +03:00
parent a0344b703f
commit 91478b8122
18 changed files with 261 additions and 234 deletions

3
.gitignore vendored
View File

@ -2,5 +2,8 @@ __pycache__/
*.pyc *.pyc
*~ *~
domain.xml domain.xml
domgen.py
na na
dist/ dist/
P@ssw0rd
*.todo

View File

@ -1,3 +1,5 @@
SRC = na/
all: build all: build
build: build:
@ -6,3 +8,10 @@ build:
clean: clean:
[ -d dist/ ] && rm -rf dist/ || true [ -d dist/ ] && rm -rf dist/ || true
find . -type d -name __pycache__ -exec rm -rf {} \; > /dev/null 2>&1 || 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)

View File

@ -123,6 +123,8 @@ print(domain_xml.to_string())
- [ ] Установка ВМ - [ ] Установка ВМ
- [x] Конструктор XML (базовый) - [x] Конструктор XML (базовый)
- [ ] Метод создания дисков
- [ ] Дефайн, запуск и автостарт ВМ
- [ ] Управление дисками - [ ] Управление дисками
- [ ] Удаление ВМ - [ ] Удаление ВМ
- [ ] Изменение CPU - [ ] Изменение CPU
@ -130,12 +132,12 @@ print(domain_xml.to_string())
- [ ] Миграция ВМ между нодами - [ ] Миграция ВМ между нодами
- [x] Работа с qemu-ga - [x] Работа с qemu-ga
- [x] Управление питанием - [x] Управление питанием
- [ ] Вкл/выкл автостарт ВМ - [x] Вкл/выкл автостарт ВМ
- [ ] Статистика потребления ресурсов - [ ] Статистика потребления ресурсов
- [ ] Получение инфомрации из/о ВМ - [ ] Получение инфомрации из/о ВМ
- [ ] SSH-ключи - [ ] SSH-ключи
- [ ] Сеть - [ ] Сеть
- [ ] ??? - [ ] Создание снапшотов
# Заметки # Заметки

View File

@ -1,3 +1,3 @@
from .main import LibvirtSession
from .config import ConfigLoader from .config import ConfigLoader
from .session import LibvirtSession
from .vm import * from .vm import *

View File

@ -13,14 +13,14 @@ Options:
-9, --sigkill Send SIGKILL to QEMU process. Not affects without --force -9, --sigkill Send SIGKILL to QEMU process. Not affects without --force
""" """
import sys
import pathlib
import logging import logging
import pathlib
import sys
import libvirt import libvirt
from docopt import docopt from docopt import docopt
from ..main import LibvirtSession from ..session import LibvirtSession
from ..vm import VirtualMachine, VMError, VMNotFound from ..vm import VirtualMachine, VMError, VMNotFound

View File

@ -10,13 +10,13 @@ Options:
-t, --timeout <sec> QEMU timeout in seconds to stop polling command status [default: 60] -t, --timeout <sec> QEMU timeout in seconds to stop polling command status [default: 60]
""" """
import sys
import pathlib
import logging import logging
import pathlib
import sys
from docopt import docopt from docopt import docopt
from ..main import LibvirtSession from ..session import LibvirtSession
from ..vm import QemuAgent, QemuAgentError, VMNotFound from ..vm import QemuAgent, QemuAgentError, VMNotFound
@ -30,6 +30,8 @@ class Color:
YELLOW = '\033[33m' YELLOW = '\033[33m'
NONE = '\033[0m' NONE = '\033[0m'
# TODO: Add STDIN support e.g.: cat something.sh | na-vmexec vmname bash
def cli(): def cli():
args = docopt(__doc__) args = docopt(__doc__)
@ -50,44 +52,28 @@ def cli():
try: try:
ga = QemuAgent(session, machine) ga = QemuAgent(session, machine)
exited, exitcode, stdout, stderr = ga.shellexec( exited, exitcode, stdout, stderr = ga.shellexec(
cmd, cmd, executable=shell, capture_output=True, decode_output=True,
executable=shell, timeout=int(args['--timeout']))
capture_output=True,
decode_output=True,
timeout=int(args['--timeout']),
)
except QemuAgentError as qemuerr: except QemuAgentError as qemuerr:
errmsg = f'{Color.RED}{err}{Color.NONE}' errmsg = f'{Color.RED}{qemuerr}{Color.NONE}'
if str(err).startswith('Polling command pid='): if str(qemuerr).startswith('Polling command pid='):
errmsg = ( errmsg = (errmsg + Color.YELLOW +
errmsg + Color.YELLOW '\n[NOTE: command may still running]' + Color.NONE)
+ '\n[NOTE: command may still running]'
+ Color.NONE
)
sys.exit(errmsg) sys.exit(errmsg)
except VMNotFound as err: except VMNotFound as err:
sys.exit( sys.exit(f'{Color.RED}VM {machine} not found{Color.NONE}')
f'{Color.RED}VM {machine} not found.{Color.NONE}'
)
if not exited: if not exited:
print( print(Color.YELLOW + '[NOTE: command may still running]' + Color.NONE,
Color.YELLOW file=sys.stderr)
+'[NOTE: command may still running]'
+ Color.NONE,
file=sys.stderr
)
else: else:
if exitcode == 0: if exitcode == 0:
exitcolor = Color.GREEN exitcolor = Color.GREEN
else: else:
exitcolor = Color.RED exitcolor = Color.RED
print( print(exitcolor + f'[command exited with exit code {exitcode}]' +
exitcolor Color.NONE,
+ f'[command exited with exit code {exitcode}]' file=sys.stderr)
+ Color.NONE,
file=sys.stderr
)
if stderr: if stderr:
print(Color.RED + stderr.strip() + Color.NONE, file=sys.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) print(Color.GREEN + stdout.strip() + Color.NONE, file=sys.stdout)
sys.exit(exitcode) sys.exit(exitcode)
if __name__ == '__main__': if __name__ == '__main__':
cli() cli()

View File

@ -1,7 +1,7 @@
import os import os
import tomllib import tomllib
from pathlib import Path
from collections import UserDict from collections import UserDict
from pathlib import Path
NODEAGENT_CONFIG_FILE = os.getenv('NODEAGENT_CONFIG_FILE') NODEAGENT_CONFIG_FILE = os.getenv('NODEAGENT_CONFIG_FILE')
@ -9,15 +9,16 @@ NODEAGENT_DEFAULT_CONFIG_FILE = '/etc/node-agent/config.toml'
class ConfigLoadError(Exception): 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): class ConfigLoader(UserDict):
def __init__(self, file: Path | None = None): def __init__(self, file: Path | None = None):
if file is None: if file is None:
file = NODEAGENT_CONFIG_FILE or NODEAGENT_DEFAULT_CONFIG_FILE file = NODEAGENT_CONFIG_FILE or NODEAGENT_DEFAULT_CONFIG_FILE
self.file = Path(file) self.file = Path(file)
self.data = self._load() super().__init__(self._load())
# todo: load deafult configuration # todo: load deafult configuration
def _load(self): def _load(self):
@ -26,6 +27,12 @@ class ConfigLoader(UserDict):
return tomllib.load(config) return tomllib.load(config)
# todo: config schema validation # todo: config schema validation
except tomllib.TOMLDecodeError as tomlerr: 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: 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()

View File

@ -1,5 +1,5 @@
from pathlib import Path
from contextlib import AbstractContextManager from contextlib import AbstractContextManager
from pathlib import Path
import libvirt import libvirt
@ -7,10 +7,11 @@ from .config import ConfigLoader
class LibvirtSessionError(Exception): class LibvirtSessionError(Exception):
"""Something went wrong while connecting to libvirt.""" """Something went wrong while connecting to libvirtd."""
class LibvirtSession(AbstractContextManager): class LibvirtSession(AbstractContextManager):
def __init__(self, config: Path | None = None): def __init__(self, config: Path | None = None):
self.config = ConfigLoader(config) self.config = ConfigLoader(config)
self.session = self._connect(self.config['libvirt']['uri']) self.session = self._connect(self.config['libvirt']['uri'])
@ -26,8 +27,7 @@ class LibvirtSession(AbstractContextManager):
return libvirt.open(connection_uri) return libvirt.open(connection_uri)
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
raise LibvirtSessionError( raise LibvirtSessionError(
f'Failed to open connection to the hypervisor: {err}' f'Failed to open connection to the hypervisor: {err}') from err
) from err
def close(self) -> None: def close(self) -> None:
self.session.close() self.session.close()

View File

@ -0,0 +1 @@
from .mac import *

16
node_agent/utils/mac.py Normal file
View File

@ -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()

View File

@ -1,7 +1,20 @@
from pathlib import Path from pathlib import Path
from lxml.etree import Element, SubElement, QName, tostring
from lxml.builder import E 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: class XMLConstructor:
@ -21,17 +34,16 @@ class XMLConstructor:
def domain_xml(self): def domain_xml(self):
return self.xml return self.xml
def gen_domain_xml( def gen_domain_xml(self,
self, name: str,
name: str, title: str,
title: str, vcpus: int,
vcpus: int, vcpu_vendor: str,
cpu_vendor: str, vcpu_model: str,
cpu_model: str, memory: int,
memory: int, volume: Path,
volume: Path, vcpu_features: dict | None = None,
desc: str = "" desc: str = "") -> None:
) -> None:
""" """
Generate default domain XML configuration for virtual machines. Generate default domain XML configuration for virtual machines.
See https://lxml.de/tutorial.html#the-e-factory for details. See https://lxml.de/tutorial.html#the-e-factory for details.
@ -54,9 +66,10 @@ class XMLConstructor:
E.apic(), E.apic(),
), ),
E.cpu( E.cpu(
E.vendor(cpu_vendor), E.vendor(vcpu_vendor),
E.model(cpu_model, fallback='forbid'), E.model(vcpu_model, fallback='forbid'),
E.topology(sockets='1', dies='1', cores=str(vcpus), threads='1'), E.topology(sockets='1', dies='1', cores=str(vcpus),
threads='1'),
mode='custom', mode='custom',
match='exact', match='exact',
check='partial', check='partial',
@ -77,25 +90,35 @@ class XMLConstructor:
type='file', type='file',
device='disk', 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', type='kvm',
) )
def gen_volume_xml( def gen_volume_xml(self,
self, device_name: str,
device_name: str, file: Path,
file: Path, bus: str = 'virtio',
bus: str = 'virtio', cache: str = 'writethrough',
cache: str = 'writethrough', disktype: str = 'file'):
disktype: str = 'file', return E.disk(E.driver(name='qemu', type='qcow2', cache=cache),
): E.source(file=file),
return E.disk( E.target(dev=device_name, bus=bus),
E.driver(name='qemu', type='qcow2', cache=cache), type=disktype,
E.source(file=file), device='disk')
E.target(dev=device_name, bus=bus),
type=disktype,
device='disk'
)
def add_volume(self): def add_volume(self):
raise NotImplementedError() raise NotImplementedError()
@ -111,21 +134,18 @@ class XMLConstructor:
data, data,
namespace=namespace, namespace=namespace,
nsprefix=nsprefix, nsprefix=nsprefix,
) ))
)
self.xml.replace(metadata_old, metadata) self.xml.replace(metadata_old, metadata)
def remove_meta(self, namespace: str): def remove_meta(self, namespace: str):
"""Remove metadata by namespace.""" """Remove metadata by namespace."""
raise NotImplementedError() raise NotImplementedError()
def construct_xml( def construct_xml(self,
self, tag: dict,
tag: dict, namespace: str | None = None,
namespace: str | None = None, nsprefix: str | None = None,
nsprefix: str | None = None, root: Element = None) -> Element:
root: Element = None,
) -> Element:
""" """
Shortly this recursive function transforms dictonary to XML. Shortly this recursive function transforms dictonary to XML.
Return etree.Element built from dict with following structure:: Return etree.Element built from dict with following structure::
@ -148,18 +168,13 @@ class XMLConstructor:
# Create element # Create element
if root is None: if root is None:
if use_ns: if use_ns:
element = Element( element = Element(QName(namespace, tag['name']),
QName(namespace, tag['name']), nsmap={nsprefix: namespace})
nsmap={nsprefix: namespace},
)
else: else:
element = Element(tag['name']) element = Element(tag['name'])
else: else:
if use_ns: if use_ns:
element = SubElement( element = SubElement(root, QName(namespace, tag['name']))
root,
QName(namespace, tag['name']),
)
else: else:
element = SubElement(root, tag['name']) element = SubElement(root, tag['name'])
# Fill up element with content # Fill up element with content
@ -171,16 +186,12 @@ class XMLConstructor:
if 'children' in tag.keys(): if 'children' in tag.keys():
for child in tag['children']: for child in tag['children']:
element.append( element.append(
self.construct_xml( self.construct_xml(child,
child, namespace=namespace,
namespace=namespace, nsprefix=nsprefix,
nsprefix=nsprefix, root=element))
root=element,
)
)
return element return element
def to_string(self): def to_string(self):
return tostring( return (tostring(self.xml, pretty_print=True,
self.xml, pretty_print=True, encoding='utf-8' encoding='utf-8').decode().strip())
).decode().strip()

View File

@ -1,3 +1,3 @@
from .main import VirtualMachine
from .ga import QemuAgent, QemuAgentError
from .exceptions import * from .exceptions import *
from .ga import QemuAgent
from .main import VirtualMachine

View File

@ -1,11 +1,11 @@
import libvirt import libvirt
from ..main import LibvirtSession from .exceptions import VMError, VMNotFound
from .exceptions import VMNotFound
class VirtualMachineBase: class VirtualMachineBase:
def __init__(self, session: LibvirtSession, name: str):
def __init__(self, session: 'LibvirtSession', name: str):
self.domname = name self.domname = name
self.session = session.session # virConnect object self.session = session.session # virConnect object
self.config = session.config # ConfigLoader object self.config = session.config # ConfigLoader object
@ -19,4 +19,4 @@ class VirtualMachineBase:
return domain return domain
raise VMNotFound(name) raise VMNotFound(name)
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
raise VMNotFound(err) from err raise VMError(err) from err

View File

@ -7,6 +7,7 @@ class VMError(Exception):
class VMNotFound(Exception): class VMNotFound(Exception):
def __init__(self, domain, message='VM not found: {domain}'): def __init__(self, domain, message='VM not found: {domain}'):
self.domain = domain self.domain = domain
self.message = message.format(domain=domain) self.message = message.format(domain=domain)

View File

@ -1,19 +1,17 @@
import json import json
import logging import logging
from time import time, sleep from base64 import b64decode, standard_b64encode
from base64 import standard_b64encode, b64decode from time import sleep, time
import libvirt import libvirt
import libvirt_qemu import libvirt_qemu
from ..main import LibvirtSession
from .base import VirtualMachineBase from .base import VirtualMachineBase
from .exceptions import QemuAgentError from .exceptions import QemuAgentError
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
QEMU_TIMEOUT = 60 # seconds QEMU_TIMEOUT = 60 # seconds
POLL_INTERVAL = 0.3 # also seconds POLL_INTERVAL = 0.3 # also seconds
@ -28,39 +26,30 @@ class QemuAgent(VirtualMachineBase):
shellexec() shellexec()
High-level method for executing shell commands on guest. Command High-level method for executing shell commands on guest. Command
must be passed as string. Wraps execute() method. 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, def __init__(self,
session: LibvirtSession, session: 'LibvirtSession',
name: str, name: str,
timeout: int | None = None, timeout: int | None = None,
flags: int | None = None flags: int | None = None):
):
super().__init__(session, name) super().__init__(session, name)
self.timeout = timeout or QEMU_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 self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
def execute( def execute(self,
self, command: dict,
command: dict, stdin: str | None = None,
stdin: str | None = None, capture_output: bool = False,
capture_output: bool = False, decode_output: bool = False,
decode_output: bool = False, wait: bool = True,
wait: bool = True, timeout: int = QEMU_TIMEOUT
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. 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: Return values:
tuple( tuple(
exited: bool | None, exited: bool | None,
@ -68,15 +57,15 @@ class QemuAgent(VirtualMachineBase):
stdout: str | None, stdout: str | None,
stderr: 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 # todo command dict schema validation
if capture_output: if capture_output:
command['arguments']['capture-output'] = True command['arguments']['capture-output'] = True
if isinstance(stdin, str): if isinstance(stdin, str):
command['arguments']['input-data'] = standard_b64encode( command['arguments']['input-data'] = standard_b64encode(
stdin.encode('utf-8') stdin.encode('utf-8')).decode('utf-8')
).decode('utf-8')
# Execute command on guest # Execute command on guest
cmd_out = self._execute(command) cmd_out = self._execute(command)
@ -91,19 +80,18 @@ class QemuAgent(VirtualMachineBase):
) )
return None, None, None, None return None, None, None, None
def shellexec( def shellexec(self,
self, command: str,
command: str, stdin: str | None = None,
stdin: str | None = None, executable: str = '/bin/sh',
executable: str = '/bin/sh', capture_output: bool = False,
capture_output: bool = False, decode_output: bool = False,
decode_output: bool = False, wait: bool = True,
wait: bool = True, timeout: int = QEMU_TIMEOUT
timeout: int = QEMU_TIMEOUT, ) -> tuple[bool | None, int | None, str | None, str | None]:
):
""" """
Execute command on guest with selected shell. /bin/sh by default. 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 = { cmd = {
'execute': 'guest-exec', 'execute': 'guest-exec',
@ -121,7 +109,6 @@ class QemuAgent(VirtualMachineBase):
timeout=timeout, timeout=timeout,
) )
def _execute(self, command: dict): def _execute(self, command: dict):
logging.debug('Execute command: vm=%s cmd=%s', self.domname, command) logging.debug('Execute command: vm=%s cmd=%s', self.domname, command)
try: try:
@ -135,19 +122,10 @@ class QemuAgent(VirtualMachineBase):
raise QemuAgentError(err) from err raise QemuAgentError(err) from err
def _get_cmd_result( def _get_cmd_result(
self, self, pid: int, decode_output: bool = False, wait: bool = True,
pid: int, timeout: int = QEMU_TIMEOUT):
decode_output: bool = False,
wait: bool = True,
timeout: int = QEMU_TIMEOUT,
):
"""Get executed command result. See GuestAgent.execute() for info.""" """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: if not wait:
output = json.loads(self._execute(cmd)) output = json.loads(self._execute(cmd))
@ -165,28 +143,23 @@ class QemuAgent(VirtualMachineBase):
raise QemuAgentError( raise QemuAgentError(
f'Polling command pid={pid} took longer than {timeout} seconds.' f'Polling command pid={pid} took longer than {timeout} seconds.'
) )
logger.debug( logger.debug('Polling command pid=%s finished, time taken: %s seconds',
'Polling command pid=%s finished, time taken: %s seconds', pid, int(time() - start_time))
pid, int(time()-start_time)
)
return self._return_tuple(output, decode=decode_output) return self._return_tuple(output, decode=decode_output)
def _return_tuple(self, cmd_output: dict, decode: bool = False): def _return_tuple(self, output: dict, decode: bool = False):
exited = cmd_output['return']['exited'] output = output['return']
exitcode = cmd_output['return']['exitcode'] exited = output['exited']
exitcode = output['exitcode']
stdout = stderr = None
try: if 'out-data' in output.keys():
stdout = cmd_output['return']['out-data'] stdout = output['out-data']
if decode and stdout: if 'err-data' in output.keys():
stdout = b64decode(stdout).decode('utf-8') stderr = output['err-data']
except KeyError:
stdout = None
try: if decode:
stderr = cmd_output['return']['err-data'] stdout = b64decode(stdout).decode('utf-8') if stdout else None
if decode and stderr: stderr = b64decode(stderr).decode('utf-8') if stderr else None
stderr = b64decode(stderr).decode('utf-8')
except KeyError:
stderr = None
return exited, exitcode, stdout, stderr return exited, exitcode, stdout, stderr

View File

@ -26,24 +26,19 @@ class VirtualMachine(VirtualMachineBase):
# https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainGetState # https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainGetState
state = self.domain.state()[0] state = self.domain.state()[0]
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
raise VMError(f'Cannot fetch VM status vm={self.domname}: {err}') from err raise VMError(
match state: f'Cannot fetch VM status vm={self.domname}: {err}') from err
case libvirt.VIR_DOMAIN_NOSTATE: STATES = {
return 'nostate' libvirt.VIR_DOMAIN_NOSTATE: 'nostate',
case libvirt.VIR_DOMAIN_RUNNING: libvirt.VIR_DOMAIN_RUNNING: 'running',
return 'running' libvirt.VIR_DOMAIN_BLOCKED: 'blocked',
case libvirt.VIR_DOMAIN_BLOCKED: libvirt.VIR_DOMAIN_PAUSED: 'paused',
return 'blocked' libvirt.VIR_DOMAIN_SHUTDOWN: 'shutdown',
case libvirt.VIR_DOMAIN_PAUSED: libvirt.VIR_DOMAIN_SHUTOFF: 'shutoff',
return 'paused' libvirt.VIR_DOMAIN_CRASHED: 'crashed',
case libvirt.VIR_DOMAIN_SHUTDOWN: libvirt.VIR_DOMAIN_PMSUSPENDED: 'pmsuspended',
return 'shutdown' }
case libvirt.VIR_DOMAIN_SHUTOFF: return STATES.get(state)
return 'shutoff'
case libvirt.VIR_DOMAIN_CRASHED:
return 'crashed'
case libvirt.VIR_DOMAIN_PMSUSPENDED:
return 'pmsuspended'
@property @property
def is_running(self) -> bool: def is_running(self) -> bool:
@ -61,42 +56,53 @@ class VirtualMachine(VirtualMachineBase):
return True return True
return False return False
except libvirt.libvirtError as err: 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: def start(self) -> None:
"""Start defined VM.""" """Start defined VM."""
logger.info('Starting VM: vm=%s', self.domname) logger.info('Starting VM: vm=%s', self.domname)
if self.is_running: 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 return
try: try:
self.domain.create() self.domain.create()
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
raise VMError(f'Cannot start vm={self.domname}: {err}') from 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. Send signal to guest OS to shutdown. Supports several modes:
Use `force=True` for graceful VM destroy. Add `sigkill=True` * GUEST_AGENT - use guest agent
to hard shutdown (may corrupt guest data!). * 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: MODES = {
flags = libvirt.VIR_DOMAIN_DESTROY_DEFAULT 'GUEST_AGENT': libvirt.VIR_DOMAIN_SHUTDOWN_GUEST_AGENT,
else: 'NORMAL': libvirt.VIR_DOMAIN_SHUTDOWN_DEFAULT,
flags = libvirt.VIR_DOMAIN_DESTROY_GRACEFUL '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: try:
if force: if mode in ['GUEST_AGENT', 'NORMAL']:
self.domain.destroyFlags(flags=flags) self.domain.shutdownFlags(flags=MODES.get(mode))
else: elif mode in ['SIGTERM', 'SIGKILL']:
# Normal VM shutdown via ACPI signal, OS may ignore this. self.domain.destroyFlags(flags=MODES.get(mode))
self.domain.shutdown()
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
raise VMError( raise VMError(f'Cannot shutdown vm={self.domname} with '
f'Cannot shutdown vm={self.domname} ' f'mode={mode}: {err}') from err
f'force={force} sigkill={sigkill}: {err}'
) from err
def reset(self): def reset(self) -> None:
""" """
Copypaste from libvirt doc: Copypaste from libvirt doc:
@ -119,35 +125,33 @@ class VirtualMachine(VirtualMachineBase):
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
raise VMError(f'Cannot reboot vm={self.domname}: {err}') from 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. Configure VM to be automatically started when the host machine boots.
""" """
if enabled: if enable:
autostart_flag = 1 autostart_flag = 1
else: else:
autostart_flag = 0 autostart_flag = 0
try: try:
self.domain.setAutostart(autostart_flag) self.domain.setAutostart(autostart_flag)
except libvirt.libvirtError as err: except libvirt.libvirtError as err:
raise VMError( raise VMError(f'Cannot set autostart vm={self.domname} '
f'Cannot set autostart vm={self.domname} ' f'autostart={autostart_flag}: {err}') from err
f'autostart={autostart_flag}: {err}'
) from err
def vcpu_set(self, count: int): def set_vcpus(self, count: int):
pass pass
def vram_set(self, count: int): def set_ram(self, count: int):
pass pass
def ssh_keys_list(self, user: str): def list_ssh_keys(self, user: str):
pass pass
def ssh_keys_add(self, user: str): def set_ssh_keys(self, user: str):
pass pass
def ssh_keys_remove(self, user: str): def remove_ssh_keys(self, user: str):
pass pass
def set_user_password(self, user: str): def set_user_password(self, user: str):

View File

@ -12,9 +12,22 @@ lxml = "^4.9.2" # 4.9.2 on Debian 12
docopt = "^0.6.2" # 0.6.2 on Debian 12 docopt = "^0.6.2" # 0.6.2 on Debian 12
[tool.poetry.scripts] [tool.poetry.scripts]
na-vmctl = "node_agent.utils.vmctl:cli" na-vmctl = "node_agent.cli.vmctl:cli"
na-vmexec = "node_agent.utils.vmexec:cli" na-vmexec = "node_agent.cli.vmexec:cli"
[build-system] [build-system]
requires = ["poetry-core"] requires = ["poetry-core"]
build-backend = "poetry.core.masonry.api" 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",
]