various updates
This commit is contained in:
parent
a0344b703f
commit
91478b8122
3
.gitignore
vendored
3
.gitignore
vendored
@ -2,5 +2,8 @@ __pycache__/
|
|||||||
*.pyc
|
*.pyc
|
||||||
*~
|
*~
|
||||||
domain.xml
|
domain.xml
|
||||||
|
domgen.py
|
||||||
na
|
na
|
||||||
dist/
|
dist/
|
||||||
|
P@ssw0rd
|
||||||
|
*.todo
|
||||||
|
9
Makefile
9
Makefile
@ -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)
|
||||||
|
@ -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-ключи
|
||||||
- [ ] Сеть
|
- [ ] Сеть
|
||||||
- [ ] ???
|
- [ ] Создание снапшотов
|
||||||
|
|
||||||
# Заметки
|
# Заметки
|
||||||
|
|
||||||
|
@ -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 *
|
||||||
|
@ -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
|
||||||
|
|
||||||
|
|
@ -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()
|
@ -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()
|
||||||
|
@ -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()
|
1
node_agent/utils/__init__.py
Normal file
1
node_agent/utils/__init__.py
Normal file
@ -0,0 +1 @@
|
|||||||
|
from .mac import *
|
16
node_agent/utils/mac.py
Normal file
16
node_agent/utils/mac.py
Normal 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()
|
@ -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()
|
|
@ -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
|
||||||
|
@ -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
|
||||||
|
@ -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)
|
||||||
|
@ -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
|
||||||
|
@ -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):
|
||||||
|
@ -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",
|
||||||
|
]
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user