some updates
This commit is contained in:
		
							
								
								
									
										5
									
								
								computelib/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										5
									
								
								computelib/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,5 @@
 | 
			
		||||
from .config import ConfigLoader
 | 
			
		||||
from .exceptions import *
 | 
			
		||||
from .session import LibvirtSession
 | 
			
		||||
from .vm import *
 | 
			
		||||
from .volume import *
 | 
			
		||||
							
								
								
									
										108
									
								
								computelib/cli/vmctl.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										108
									
								
								computelib/cli/vmctl.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,108 @@
 | 
			
		||||
"""
 | 
			
		||||
Manage virtual machines.
 | 
			
		||||
 | 
			
		||||
Usage:  na-vmctl [options] status <machine>
 | 
			
		||||
        na-vmctl [options] is-running <machine>
 | 
			
		||||
        na-vmctl [options] start <machine>
 | 
			
		||||
        na-vmctl [options] shutdown <machine>
 | 
			
		||||
        na-vmctl [options] set-vcpus <machine> <nvcpus>
 | 
			
		||||
        na-vmctl [options] set-memory <machine> <memory>
 | 
			
		||||
        na-vmctl [options] list [-a|--all]
 | 
			
		||||
 | 
			
		||||
Options:
 | 
			
		||||
    -c, --config <file>  config file [default: /etc/node-agent/config.yaml]
 | 
			
		||||
    -l, --loglvl <lvl>   logging level
 | 
			
		||||
    -a, --all            list all machines including inactive
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
import pathlib
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
from docopt import docopt
 | 
			
		||||
 | 
			
		||||
from ..exceptions import VMError, VMNotFound
 | 
			
		||||
from ..session import LibvirtSession
 | 
			
		||||
from ..vm import VirtualMachine
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
levels = logging.getLevelNamesMapping()
 | 
			
		||||
 | 
			
		||||
libvirt.registerErrorHandler(lambda userdata, err: None, ctx=None)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Color:
 | 
			
		||||
    RED = '\033[31m'
 | 
			
		||||
    GREEN = '\033[32m'
 | 
			
		||||
    YELLOW = '\033[33m'
 | 
			
		||||
    NONE = '\033[0m'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Table:
 | 
			
		||||
 | 
			
		||||
    def __init__(self, whitespace: str = '\t'):
 | 
			
		||||
        self.__rows = []
 | 
			
		||||
        self.__whitespace = whitespace
 | 
			
		||||
 | 
			
		||||
    def header(self, columns: list):
 | 
			
		||||
        self.__rows.insert(0, [str(col) for col in columns])
 | 
			
		||||
 | 
			
		||||
    def row(self, row: list):
 | 
			
		||||
        self.__rows.append([str(col) for col in row])
 | 
			
		||||
 | 
			
		||||
    def rows(self, rows: list):
 | 
			
		||||
        for row in rows:
 | 
			
		||||
            self.row(row)
 | 
			
		||||
 | 
			
		||||
    def print(self):
 | 
			
		||||
        widths = [max(map(len, col)) for col in zip(*self.__rows)]
 | 
			
		||||
        for row in self.__rows:
 | 
			
		||||
            print(self.__whitespace.join(
 | 
			
		||||
                (val.ljust(width) for val, width in zip(row, widths))))
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cli():
 | 
			
		||||
    args = docopt(__doc__)
 | 
			
		||||
    config = pathlib.Path(args['--config']) or None
 | 
			
		||||
    loglvl = None
 | 
			
		||||
    machine = args['<machine>']
 | 
			
		||||
 | 
			
		||||
    if args['--loglvl']:
 | 
			
		||||
        loglvl = args['--loglvl'].upper()
 | 
			
		||||
 | 
			
		||||
    if loglvl in levels:
 | 
			
		||||
        logging.basicConfig(level=levels[loglvl])
 | 
			
		||||
 | 
			
		||||
    with LibvirtSession() as session:
 | 
			
		||||
        try:
 | 
			
		||||
            if args['list']:
 | 
			
		||||
                table = Table()
 | 
			
		||||
                table.header(['NAME', 'STATE', 'AUTOSTART'])
 | 
			
		||||
                for vm_ in session.list_machines():
 | 
			
		||||
                    table.row([vm_.name, vm_.status, vm_.is_autostart])
 | 
			
		||||
                table.print()
 | 
			
		||||
                sys.exit()
 | 
			
		||||
 | 
			
		||||
            vm = session.get_machine(machine)
 | 
			
		||||
            if args['status']:
 | 
			
		||||
                print(vm.status)
 | 
			
		||||
            if args['is-running']:
 | 
			
		||||
                if vm.is_running:
 | 
			
		||||
                    print('running')
 | 
			
		||||
                else:
 | 
			
		||||
                    sys.exit(vm.status)
 | 
			
		||||
            if args['start']:
 | 
			
		||||
                vm.start()
 | 
			
		||||
                print(f'{vm.name} started')
 | 
			
		||||
            if args['shutdown']:
 | 
			
		||||
                vm.shutdown('NORMAL')
 | 
			
		||||
        except VMNotFound as nferr:
 | 
			
		||||
            sys.exit(f'{Color.RED}VM {machine} not found.{Color.NONE}')
 | 
			
		||||
        except VMError as vmerr:
 | 
			
		||||
            sys.exit(f'{Color.RED}{vmerr}{Color.NONE}')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    cli()
 | 
			
		||||
							
								
								
									
										84
									
								
								computelib/cli/vmexec.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										84
									
								
								computelib/cli/vmexec.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,84 @@
 | 
			
		||||
"""
 | 
			
		||||
Execute shell commands on guest via guest agent.
 | 
			
		||||
 | 
			
		||||
Usage:  na-vmexec [options] <machine> <command>
 | 
			
		||||
 | 
			
		||||
Options:
 | 
			
		||||
    -c, --config <file>  config file [default: /etc/node-agent/config.yaml]
 | 
			
		||||
    -l, --loglvl <lvl>   logging level
 | 
			
		||||
    -s, --shell <shell>  guest shell [default: /bin/sh]
 | 
			
		||||
    -t, --timeout <sec>  QEMU timeout in seconds to stop polling command status [default: 60]
 | 
			
		||||
    -p, --pid <PID>      PID on guest to poll output
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import logging
 | 
			
		||||
import pathlib
 | 
			
		||||
import sys
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
from docopt import docopt
 | 
			
		||||
 | 
			
		||||
from ..exceptions import GuestAgentError, VMNotFound
 | 
			
		||||
from ..session import LibvirtSession
 | 
			
		||||
from ..vm import GuestAgent
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
levels = logging.getLevelNamesMapping()
 | 
			
		||||
 | 
			
		||||
libvirt.registerErrorHandler(lambda userdata, err: None, ctx=None)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Color:
 | 
			
		||||
    RED = '\033[31m'
 | 
			
		||||
    GREEN = '\033[32m'
 | 
			
		||||
    YELLOW = '\033[33m'
 | 
			
		||||
    NONE = '\033[0m'
 | 
			
		||||
 | 
			
		||||
# TODO: Add STDIN support e.g.: cat something.sh | na-vmexec vmname bash
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cli():
 | 
			
		||||
    args = docopt(__doc__)
 | 
			
		||||
    config = pathlib.Path(args['--config']) or None
 | 
			
		||||
    loglvl = None
 | 
			
		||||
    machine = args['<machine>']
 | 
			
		||||
 | 
			
		||||
    if args['--loglvl']:
 | 
			
		||||
        loglvl = args['--loglvl'].upper()
 | 
			
		||||
 | 
			
		||||
    if loglvl in levels:
 | 
			
		||||
        logging.basicConfig(level=levels[loglvl])
 | 
			
		||||
 | 
			
		||||
    with LibvirtSession() as session:
 | 
			
		||||
        shell = args['--shell']
 | 
			
		||||
        cmd = args['<command>']
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            ga = session.get_guest_agent(machine)
 | 
			
		||||
            exited, exitcode, stdout, stderr = ga.shellexec(
 | 
			
		||||
                cmd, executable=shell, capture_output=True, decode_output=True,
 | 
			
		||||
                timeout=int(args['--timeout']))
 | 
			
		||||
        except GuestAgentError as gaerr:
 | 
			
		||||
            errmsg = f'{Color.RED}{gaerr}{Color.NONE}'
 | 
			
		||||
            if str(gaerr).startswith('Polling command pid='):
 | 
			
		||||
                errmsg = (errmsg + Color.YELLOW +
 | 
			
		||||
                          '\n[NOTE: command may still running on guest '
 | 
			
		||||
                          'pid={ga.last_pid}]' + Color.NONE)
 | 
			
		||||
            sys.exit(errmsg)
 | 
			
		||||
        except VMNotFound as err:
 | 
			
		||||
            sys.exit(f'{Color.RED}VM {machine} not found{Color.NONE}')
 | 
			
		||||
 | 
			
		||||
    if not exited:
 | 
			
		||||
        print(Color.YELLOW +
 | 
			
		||||
              '[NOTE: command may still running on guest pid={ga.last_pid}]' +
 | 
			
		||||
              Color.NONE, file=sys.stderr)
 | 
			
		||||
    if stderr:
 | 
			
		||||
        print(stderr.strip(), file=sys.stderr)
 | 
			
		||||
    if stdout:
 | 
			
		||||
        print(stdout.strip(), file=sys.stdout)
 | 
			
		||||
    sys.exit(exitcode)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    cli()
 | 
			
		||||
							
								
								
									
										0
									
								
								computelib/cli/volctl.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								computelib/cli/volctl.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										36
									
								
								computelib/config.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										36
									
								
								computelib/config.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,36 @@
 | 
			
		||||
import os
 | 
			
		||||
import tomllib
 | 
			
		||||
from collections import UserDict
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
 | 
			
		||||
from .exceptions import ConfigLoaderError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
NODEAGENT_CONFIG_FILE = os.getenv('NODEAGENT_CONFIG_FILE')
 | 
			
		||||
NODEAGENT_DEFAULT_CONFIG_FILE = '/etc/node-agent/config.toml'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
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)
 | 
			
		||||
        super().__init__(self._load())
 | 
			
		||||
        # todo: load deafult configuration
 | 
			
		||||
 | 
			
		||||
    def _load(self) -> dict:
 | 
			
		||||
        try:
 | 
			
		||||
            with open(self.file, 'rb') as config:
 | 
			
		||||
                return tomllib.load(config)
 | 
			
		||||
                # todo: config schema validation
 | 
			
		||||
        except tomllib.TOMLDecodeError as tomlerr:
 | 
			
		||||
            raise ConfigLoaderError(
 | 
			
		||||
                f'Bad TOML syntax in config file: {self.file}: {tomlerr}'
 | 
			
		||||
            ) from tomlerr
 | 
			
		||||
        except (OSError, ValueError) as readerr:
 | 
			
		||||
            raise ConfigLoaderError(
 | 
			
		||||
                f'Cannot read config file: {self.file}: {readerr}') from readerr
 | 
			
		||||
 | 
			
		||||
    def reload(self) -> None:
 | 
			
		||||
        self.data = self._load()
 | 
			
		||||
							
								
								
									
										22
									
								
								computelib/exceptions.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								computelib/exceptions.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
class ConfigLoaderError(Exception):
 | 
			
		||||
    """Bad config file syntax, unreachable file or bad config schema."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LibvirtSessionError(Exception):
 | 
			
		||||
    """Something went wrong while connecting to libvirtd."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VMError(Exception):
 | 
			
		||||
    """Something went wrong while interacting with the domain."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VMNotFound(VMError):
 | 
			
		||||
    """Virtual machine not found on node."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GuestAgentError(Exception):
 | 
			
		||||
    """Mostly QEMU Guest Agent is not responding."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StoragePoolError(Exception):
 | 
			
		||||
    """Something went wrong when operating with storage pool."""
 | 
			
		||||
							
								
								
									
										52
									
								
								computelib/session.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										52
									
								
								computelib/session.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,52 @@
 | 
			
		||||
from contextlib import AbstractContextManager
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
 | 
			
		||||
from .exceptions import LibvirtSessionError, VMNotFound
 | 
			
		||||
from .vm import GuestAgent, VirtualMachine
 | 
			
		||||
from .volume import StoragePool
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LibvirtSession(AbstractContextManager):
 | 
			
		||||
 | 
			
		||||
    def __init__(self, uri: str = 'qemu:///system'):
 | 
			
		||||
        try:
 | 
			
		||||
            self.connection = libvirt.open(uri)
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise LibvirtSessionError(err) from err
 | 
			
		||||
 | 
			
		||||
    def __enter__(self):
 | 
			
		||||
        return self
 | 
			
		||||
 | 
			
		||||
    def __exit__(self, exception_type, exception_value, exception_traceback):
 | 
			
		||||
        self.close()
 | 
			
		||||
 | 
			
		||||
    def get_machine(self, name: str) -> VirtualMachine:
 | 
			
		||||
        try:
 | 
			
		||||
            return VirtualMachine(self.connection.lookupByName(name))
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
 | 
			
		||||
                raise VMNotFound(name) from err
 | 
			
		||||
            raise LibvirtSessionError(err) from err
 | 
			
		||||
 | 
			
		||||
    def list_machines(self) -> list[VirtualMachine]:
 | 
			
		||||
        return [VirtualMachine(dom) for dom in
 | 
			
		||||
                self.connection.listAllDomains()]
 | 
			
		||||
 | 
			
		||||
    def get_guest_agent(self, name: str,
 | 
			
		||||
                        timeout: int | None = None) -> GuestAgent:
 | 
			
		||||
        try:
 | 
			
		||||
            return GuestAgent(self.connection.lookupByName(name), timeout)
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            if err.get_error_code() == libvirt.VIR_ERR_NO_DOMAIN:
 | 
			
		||||
                raise VMNotFound(name) from err
 | 
			
		||||
            raise LibvirtSessionError(err) from err
 | 
			
		||||
 | 
			
		||||
    def get_storage_pool(self, name: str) -> StoragePool:
 | 
			
		||||
        return StoragePool(self.connection.storagePoolLookupByName(name))
 | 
			
		||||
 | 
			
		||||
    def list_storage_pools(self) -> list[StoragePool]:
 | 
			
		||||
        return [StoragePool(p) for p in self.connection.listStoragePools()]
 | 
			
		||||
 | 
			
		||||
    def close(self) -> None:
 | 
			
		||||
        self.connection.close()
 | 
			
		||||
							
								
								
									
										1
									
								
								computelib/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								computelib/utils/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1 @@
 | 
			
		||||
from . import mac, xml
 | 
			
		||||
							
								
								
									
										10
									
								
								computelib/utils/mac.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										10
									
								
								computelib/utils/mac.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,10 @@
 | 
			
		||||
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))
 | 
			
		||||
							
								
								
									
										73
									
								
								computelib/utils/xml.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73
									
								
								computelib/utils/xml.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,73 @@
 | 
			
		||||
from lxml.etree import Element, QName, SubElement
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Constructor:
 | 
			
		||||
    """
 | 
			
		||||
    The XML constructor. This class builds XML configs for libvirt.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    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': 'device',  # 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 add_meta(self, xml: Element, data: dict,
 | 
			
		||||
                 namespace: str, nsprefix: str) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Add metadata to domain. See:
 | 
			
		||||
        https://libvirt.org/formatdomain.html#general-metadata
 | 
			
		||||
        """
 | 
			
		||||
        metadata = metadata_old = xml.xpath('/domain/metadata')[0]
 | 
			
		||||
        metadata.append(
 | 
			
		||||
            self.construct_xml(
 | 
			
		||||
                data,
 | 
			
		||||
                namespace=namespace,
 | 
			
		||||
                nsprefix=nsprefix,
 | 
			
		||||
            ))
 | 
			
		||||
        xml.replace(metadata_old, metadata)
 | 
			
		||||
							
								
								
									
										3
									
								
								computelib/vm/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										3
									
								
								computelib/vm/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,3 @@
 | 
			
		||||
from .guest_agent import GuestAgent
 | 
			
		||||
from .installer import VirtualMachineInstaller
 | 
			
		||||
from .virtual_machine import VirtualMachine
 | 
			
		||||
							
								
								
									
										30
									
								
								computelib/vm/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										30
									
								
								computelib/vm/base.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,30 @@
 | 
			
		||||
import libvirt
 | 
			
		||||
 | 
			
		||||
from ..exceptions import VMError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VirtualMachineBase:
 | 
			
		||||
 | 
			
		||||
    def __init__(self, domain: libvirt.virDomain):
 | 
			
		||||
        self.domain = domain
 | 
			
		||||
        self.domain_name = self._get_domain_name()
 | 
			
		||||
        self.domain_info = self._get_domain_info()
 | 
			
		||||
 | 
			
		||||
    def _get_domain_name(self):
 | 
			
		||||
        try:
 | 
			
		||||
            return self.domain.name()
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMError(f'Cannot get domain name: {err}') from err
 | 
			
		||||
 | 
			
		||||
    def _get_domain_info(self):
 | 
			
		||||
        try:
 | 
			
		||||
            info = self.domain.info()
 | 
			
		||||
            return {
 | 
			
		||||
                'state': info[0],
 | 
			
		||||
                'max_memory': info[1],
 | 
			
		||||
                'memory': info[2],
 | 
			
		||||
                'nproc': info[3],
 | 
			
		||||
                'cputime': info[4]
 | 
			
		||||
            }
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMError(f'Cannot get domain info: {err}') from err
 | 
			
		||||
							
								
								
									
										179
									
								
								computelib/vm/guest_agent.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										179
									
								
								computelib/vm/guest_agent.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,179 @@
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
from base64 import b64decode, standard_b64encode
 | 
			
		||||
from time import sleep, time
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
import libvirt_qemu
 | 
			
		||||
 | 
			
		||||
from ..exceptions import GuestAgentError
 | 
			
		||||
from .base import VirtualMachineBase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
QEMU_TIMEOUT = 60  # in seconds
 | 
			
		||||
POLL_INTERVAL = 0.3  # also in seconds
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class GuestAgent(VirtualMachineBase):
 | 
			
		||||
    """
 | 
			
		||||
    Interacting with QEMU guest agent. Methods:
 | 
			
		||||
 | 
			
		||||
    execute()
 | 
			
		||||
        Low-level method for executing QEMU command as dict. Command dict
 | 
			
		||||
        internally converts to JSON. See method docstring for more info.
 | 
			
		||||
    shellexec()
 | 
			
		||||
        High-level method for executing shell commands on guest. Command
 | 
			
		||||
        must be passed as string. Wraps execute() method.
 | 
			
		||||
 | 
			
		||||
    TODO:
 | 
			
		||||
        check() method. Ping guest agent and check supported commands.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self, domain: libvirt.virDomain, timeout: int | None = None,
 | 
			
		||||
                 flags: int | None = None):
 | 
			
		||||
        super().__init__(domain)
 | 
			
		||||
        self.timeout = timeout or QEMU_TIMEOUT  # timeout for guest agent
 | 
			
		||||
        self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
 | 
			
		||||
        self.last_pid = None
 | 
			
		||||
 | 
			
		||||
    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.
 | 
			
		||||
        See https://wiki.qemu.org/Documentation/QMP for QEMU commands reference.
 | 
			
		||||
        If `wait` is True poll guest command output with POLL_INTERVAL. Raise
 | 
			
		||||
        GuestAgentError on `timeout` reached (in seconds).
 | 
			
		||||
        Return values:
 | 
			
		||||
            tuple(
 | 
			
		||||
                exited: bool | None,
 | 
			
		||||
                exitcode: int | None,
 | 
			
		||||
                stdout: str | None,
 | 
			
		||||
                stderr: str | 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')
 | 
			
		||||
 | 
			
		||||
        # Execute command on guest
 | 
			
		||||
        cmd_out = self._execute(command)
 | 
			
		||||
 | 
			
		||||
        if capture_output:
 | 
			
		||||
            self.last_pid = json.loads(cmd_out)['return']['pid']
 | 
			
		||||
            return self._get_cmd_result(
 | 
			
		||||
                self.last_pid,
 | 
			
		||||
                decode_output=decode_output,
 | 
			
		||||
                wait=wait,
 | 
			
		||||
                timeout=timeout,
 | 
			
		||||
            )
 | 
			
		||||
        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
 | 
			
		||||
                  ) -> 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 shell command as string.
 | 
			
		||||
        """
 | 
			
		||||
        cmd = {
 | 
			
		||||
            'execute': 'guest-exec',
 | 
			
		||||
            'arguments': {
 | 
			
		||||
                'path': executable,
 | 
			
		||||
                'arg': ['-c', command],
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
        return self.execute(
 | 
			
		||||
            cmd,
 | 
			
		||||
            stdin=stdin,
 | 
			
		||||
            capture_output=capture_output,
 | 
			
		||||
            decode_output=decode_output,
 | 
			
		||||
            wait=wait,
 | 
			
		||||
            timeout=timeout,
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def poll_pid(self, pid: int):
 | 
			
		||||
        # Нужно цепляться к PID и вывести результат
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def _execute(self, command: dict):
 | 
			
		||||
        logging.debug('Execute command: vm=%s cmd=%s', self.domain_name,
 | 
			
		||||
                      command)
 | 
			
		||||
        if self.domain_info['state'] != libvirt.VIR_DOMAIN_RUNNING:
 | 
			
		||||
            raise GuestAgentError(
 | 
			
		||||
                f'Cannot execute command: vm={self.domain_name} is not running')
 | 
			
		||||
        try:
 | 
			
		||||
            return libvirt_qemu.qemuAgentCommand(
 | 
			
		||||
                self.domain,  # virDomain object
 | 
			
		||||
                json.dumps(command),
 | 
			
		||||
                self.timeout,
 | 
			
		||||
                self.flags,
 | 
			
		||||
            )
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise GuestAgentError(
 | 
			
		||||
                f'Cannot execute command on vm={self.domain_name}: {err}'
 | 
			
		||||
            ) from err
 | 
			
		||||
 | 
			
		||||
    def _get_cmd_result(
 | 
			
		||||
            self, pid: int, decode_output: bool = False, wait: bool = True,
 | 
			
		||||
            timeout: int = QEMU_TIMEOUT):
 | 
			
		||||
        """Get executed command result. See GuestAgent.execute() for info."""
 | 
			
		||||
        cmd = {'execute': 'guest-exec-status', 'arguments': {'pid': pid}}
 | 
			
		||||
 | 
			
		||||
        if not wait:
 | 
			
		||||
            output = json.loads(self._execute(cmd))
 | 
			
		||||
            return self._return_tuple(output, decode=decode_output)
 | 
			
		||||
 | 
			
		||||
        logger.debug('Start polling command pid=%s on vm=%s', pid,
 | 
			
		||||
                     self.domain_name)
 | 
			
		||||
        start_time = time()
 | 
			
		||||
        while True:
 | 
			
		||||
            output = json.loads(self._execute(cmd))
 | 
			
		||||
            if output['return']['exited']:
 | 
			
		||||
                break
 | 
			
		||||
            sleep(POLL_INTERVAL)
 | 
			
		||||
            now = time()
 | 
			
		||||
            if now - start_time > timeout:
 | 
			
		||||
                raise GuestAgentError(
 | 
			
		||||
                    f'Polling command pid={pid} on vm={self.domain_name} '
 | 
			
		||||
                    f'took longer than {timeout} seconds.'
 | 
			
		||||
                )
 | 
			
		||||
        logger.debug('Polling command pid=%s on vm=%s finished, '
 | 
			
		||||
                     'time taken: %s seconds',
 | 
			
		||||
                     pid, self.domain_name, int(time() - start_time))
 | 
			
		||||
        return self._return_tuple(output, decode=decode_output)
 | 
			
		||||
 | 
			
		||||
    def _return_tuple(self, output: dict, decode: bool = False):
 | 
			
		||||
        output = output['return']
 | 
			
		||||
        exited = output['exited']
 | 
			
		||||
        exitcode = output['exitcode']
 | 
			
		||||
        stdout = stderr = None
 | 
			
		||||
 | 
			
		||||
        if 'out-data' in output.keys():
 | 
			
		||||
            stdout = output['out-data']
 | 
			
		||||
        if 'err-data' in output.keys():
 | 
			
		||||
            stderr = output['err-data']
 | 
			
		||||
 | 
			
		||||
        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
 | 
			
		||||
							
								
								
									
										168
									
								
								computelib/vm/installer.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										168
									
								
								computelib/vm/installer.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,168 @@
 | 
			
		||||
import textwrap
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from enum import Enum
 | 
			
		||||
 | 
			
		||||
from lxml import etree
 | 
			
		||||
from lxml.builder import E
 | 
			
		||||
 | 
			
		||||
from ..utils import mac
 | 
			
		||||
from ..volume import DiskInfo, VolumeInfo
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class VirtualMachineInfo:
 | 
			
		||||
    name: str
 | 
			
		||||
    title: str
 | 
			
		||||
    memory: int
 | 
			
		||||
    vcpus: int
 | 
			
		||||
    machine: str
 | 
			
		||||
    emulator: str
 | 
			
		||||
    arch: str
 | 
			
		||||
    cpu: str  # CPU full XML description
 | 
			
		||||
    mac: str
 | 
			
		||||
    description: str = ''
 | 
			
		||||
    boot_order: tuple = ('cdrom', 'hd')
 | 
			
		||||
 | 
			
		||||
    def to_xml(self) -> str:
 | 
			
		||||
        xml = E.domain(
 | 
			
		||||
            E.name(self.name),
 | 
			
		||||
            E.title(self.title),
 | 
			
		||||
            E.description(self.description),
 | 
			
		||||
            E.metadata(),
 | 
			
		||||
            E.memory(str(self.memory), unit='MB'),
 | 
			
		||||
            E.currentMemory(str(self.memory), unit='MB'),
 | 
			
		||||
            E.vcpu(str(self.vcpus), placement='static'),
 | 
			
		||||
            type='kvm')
 | 
			
		||||
        os = E.os(E.type('hvm', machine=self.machine, arch=self.arch))
 | 
			
		||||
        for dev in self.boot_order:
 | 
			
		||||
            os.append(E.boot(dev=dev))
 | 
			
		||||
        xml.append(os)
 | 
			
		||||
        xml.append(E.features(E.acpi(), E.apic()))
 | 
			
		||||
        xml.append(etree.fromstring(self.cpu))
 | 
			
		||||
        xml.append(E.on_poweroff('destroy'))
 | 
			
		||||
        xml.append(E.on_reboot('restart'))
 | 
			
		||||
        xml.append(E.on_crash('restart'))
 | 
			
		||||
        xml.append(E.pm(
 | 
			
		||||
            E('suspend-to-mem', enabled='no'),
 | 
			
		||||
            E('suspend-to-disk', enabled='no'))
 | 
			
		||||
        )
 | 
			
		||||
        devices = E.devices()
 | 
			
		||||
        devices.append(E.emulator(self.emulator))
 | 
			
		||||
        devices.append(E.interface(
 | 
			
		||||
            E.source(network='default'),
 | 
			
		||||
            E.mac(address=self.mac),
 | 
			
		||||
            type='network'))
 | 
			
		||||
        devices.append(E.graphics(type='vnc', port='-1', autoport='yes'))
 | 
			
		||||
        devices.append(E.input(type='tablet', bus='usb'))
 | 
			
		||||
        devices.append(E.channel(
 | 
			
		||||
            E.source(mode='bind'),
 | 
			
		||||
            E.target(type='virtio', name='org.qemu.guest_agent.0'),
 | 
			
		||||
            E.address(type='virtio-serial', controller='0', bus='0', port='1'),
 | 
			
		||||
            type='unix')
 | 
			
		||||
        )
 | 
			
		||||
        devices.append(E.console(
 | 
			
		||||
            E.target(type='serial', port='0'),
 | 
			
		||||
            type='pty')
 | 
			
		||||
        )
 | 
			
		||||
        devices.append(E.video(
 | 
			
		||||
            E.model(type='vga', vram='16384', heads='1', primary='yes'))
 | 
			
		||||
        )
 | 
			
		||||
        xml.append(devices)
 | 
			
		||||
        return etree.tostring(xml, encoding='unicode', pretty_print=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class CPUMode(Enum):
 | 
			
		||||
    HOST_MODEL = 'host-model'
 | 
			
		||||
    HOST_PASSTHROUGH = 'host-passthrough'
 | 
			
		||||
    CUSTOM = 'custom'
 | 
			
		||||
    MAXIMUM = 'maximum'
 | 
			
		||||
 | 
			
		||||
    @classmethod
 | 
			
		||||
    def default(cls):
 | 
			
		||||
        return cls.HOST_PASSTHROUGH
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class CPUTopology:
 | 
			
		||||
    sockets: int
 | 
			
		||||
    cores: int
 | 
			
		||||
    threads: int
 | 
			
		||||
 | 
			
		||||
    def validate(self, vcpus: int) -> None:
 | 
			
		||||
        if self.sockets * self.cores * self.threads == vcpus:
 | 
			
		||||
            return
 | 
			
		||||
        raise ValueError("CPU topology must match the number of 'vcpus'")
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VirtualMachineInstaller:
 | 
			
		||||
 | 
			
		||||
    def __init__(self, session: 'LibvirtSession'):
 | 
			
		||||
        self.session = session
 | 
			
		||||
        self.connection = session.connection  # libvirt.virConnect object
 | 
			
		||||
        self.domcaps = etree.fromstring(
 | 
			
		||||
            self.connection.getDomainCapabilities())
 | 
			
		||||
        self.arch = self.domcaps.xpath('/domainCapabilities/arch/text()')[0]
 | 
			
		||||
        self.virttype = self.domcaps.xpath(
 | 
			
		||||
            '/domainCapabilities/domain/text()')[0]
 | 
			
		||||
        self.emulator = self.domcaps.xpath(
 | 
			
		||||
            '/domainCapabilities/path/text()')[0]
 | 
			
		||||
        self.machine = self.domcaps.xpath(
 | 
			
		||||
            '/domainCapabilities/machine/text()')[0]
 | 
			
		||||
 | 
			
		||||
    def install(self, data: 'VirtualMachineSchema'):
 | 
			
		||||
        xml_cpu = self._choose_best_cpu(CPUMode.default())
 | 
			
		||||
        xml_vm = VirtualMachineInfo(
 | 
			
		||||
            name=data['name'],
 | 
			
		||||
            title=data['title'],
 | 
			
		||||
            vcpus=data['vcpus'],
 | 
			
		||||
            memory=data['memory'],
 | 
			
		||||
            machine=self.machine,
 | 
			
		||||
            emulator=self.emulator,
 | 
			
		||||
            arch=self.arch,
 | 
			
		||||
            cpu=xml_cpu,
 | 
			
		||||
            mac=mac.random_mac()
 | 
			
		||||
        ).to_xml()
 | 
			
		||||
        self._define(xml_vm)
 | 
			
		||||
        storage_pool = self.session.get_storage_pool('default')
 | 
			
		||||
        etalon_vol = storage_pool.get_volume('bookworm.qcow2')
 | 
			
		||||
        new_vol = VolumeInfo(
 | 
			
		||||
            name=data['name'] +
 | 
			
		||||
            '_disk_some_pattern.qcow2',
 | 
			
		||||
            path=storage_pool.path +
 | 
			
		||||
            '/' +
 | 
			
		||||
            data['name'] +
 | 
			
		||||
            '_disk_some_pattern.qcow2',
 | 
			
		||||
            capacity=data['volume']['capacity'])
 | 
			
		||||
        etalon_vol.clone(new_vol)
 | 
			
		||||
        vm = self.session.get_machine(data['name'])
 | 
			
		||||
        vm.attach_device(DiskInfo(path=new_vol.path, target='vda'))
 | 
			
		||||
        vm.set_vcpus(data['vcpus'])
 | 
			
		||||
        vm.set_memory(data['memory'])
 | 
			
		||||
        vm.start()
 | 
			
		||||
        vm.set_autostart(enabled=True)
 | 
			
		||||
 | 
			
		||||
    def _choose_best_cpu(self, mode: CPUMode) -> str:
 | 
			
		||||
        if mode == 'host-passthrough':
 | 
			
		||||
            xml = '<cpu mode="host-passthrough" migratable="on"/>'
 | 
			
		||||
        elif mode == 'maximum':
 | 
			
		||||
            xml = '<cpu mode="maximum" migratable="on"/>'
 | 
			
		||||
        elif mode in ['host-model', 'custom']:
 | 
			
		||||
            cpus = self.domcaps.xpath(
 | 
			
		||||
                f'/domainCapabilities/cpu/mode[@name="{mode}"]')[0]
 | 
			
		||||
            cpus.tag = 'cpu'
 | 
			
		||||
            for attr in cpus.attrib.keys():
 | 
			
		||||
                del cpus.attrib[attr]
 | 
			
		||||
            arch = etree.SubElement(cpus, 'arch')
 | 
			
		||||
            arch.text = self.arch
 | 
			
		||||
            xmlcpus = etree.tostring(
 | 
			
		||||
                cpus, encoding='unicode', pretty_print=True)
 | 
			
		||||
            xml = self.connection.baselineHypervisorCPU(
 | 
			
		||||
                self.emulator, self.arch, self.machine, self.virttype, [xmlcpus])
 | 
			
		||||
        else:
 | 
			
		||||
            raise ValueError(
 | 
			
		||||
                f'CPU mode must be in {[v.value for v in CPUMode]}, '
 | 
			
		||||
                f"but passed '{mode}'")
 | 
			
		||||
        return textwrap.indent(xml, ' ' * 2)
 | 
			
		||||
 | 
			
		||||
    def _define(self, xml: str) -> None:
 | 
			
		||||
        self.connection.defineXML(xml)
 | 
			
		||||
							
								
								
									
										233
									
								
								computelib/vm/virtual_machine.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										233
									
								
								computelib/vm/virtual_machine.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,233 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
 | 
			
		||||
from ..exceptions import VMError
 | 
			
		||||
from ..volume import VolumeInfo
 | 
			
		||||
from .base import VirtualMachineBase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VirtualMachine(VirtualMachineBase):
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self):
 | 
			
		||||
        return self.domain_name
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def status(self) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        Return VM state: 'running', 'shutoff', etc. Reference:
 | 
			
		||||
        https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
 | 
			
		||||
        """
 | 
			
		||||
        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.domain_name}: {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:
 | 
			
		||||
        """Return True if VM is running, else return False."""
 | 
			
		||||
        if self.domain.isActive() != 1:
 | 
			
		||||
            # inactive (0) or error (-1)
 | 
			
		||||
            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.domain_name}: {err}'
 | 
			
		||||
            ) from err
 | 
			
		||||
 | 
			
		||||
    def start(self) -> None:
 | 
			
		||||
        """Start defined VM."""
 | 
			
		||||
        logger.info('Starting VM: vm=%s', self.domain_name)
 | 
			
		||||
        if self.is_running:
 | 
			
		||||
            logger.warning('VM vm=%s is already started, nothing to do',
 | 
			
		||||
                           self.domain_name)
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            self.domain.create()
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMError(
 | 
			
		||||
                f'Cannot start vm={self.domain_name}: {err}') from err
 | 
			
		||||
 | 
			
		||||
    def shutdown(self, method: str | None = None) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        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 to QEMU process. May corrupt guest data!
 | 
			
		||||
        If mode is not passed use 'NORMAL' mode.
 | 
			
		||||
        """
 | 
			
		||||
        METHODS = {
 | 
			
		||||
            '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 method is None:
 | 
			
		||||
            method = 'NORMAL'
 | 
			
		||||
        if not isinstance(method, str):
 | 
			
		||||
            raise ValueError(f"Mode must be a 'str', not {type(method)}")
 | 
			
		||||
        if method.upper() not in METHODS:
 | 
			
		||||
            raise ValueError(f"Unsupported mode: '{method}'")
 | 
			
		||||
        try:
 | 
			
		||||
            if method in ['GUEST_AGENT', 'NORMAL']:
 | 
			
		||||
                self.domain.shutdownFlags(flags=METHODS.get(method))
 | 
			
		||||
            elif method in ['SIGTERM', 'SIGKILL']:
 | 
			
		||||
                self.domain.destroyFlags(flags=METHODS.get(method))
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMError(f'Cannot shutdown vm={self.domain_name} with '
 | 
			
		||||
                          f'method={method}: {err}') from err
 | 
			
		||||
 | 
			
		||||
    def reset(self) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        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.
 | 
			
		||||
 | 
			
		||||
        Note that there is a risk of data loss caused by reset without any
 | 
			
		||||
        guest OS shutdown.
 | 
			
		||||
        """
 | 
			
		||||
        try:
 | 
			
		||||
            self.domain.reset()
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMError(
 | 
			
		||||
                f'Cannot reset vm={self.domain_name}: {err}') from err
 | 
			
		||||
 | 
			
		||||
    def reboot(self) -> None:
 | 
			
		||||
        """Send ACPI signal to guest OS to reboot. OS may ignore this."""
 | 
			
		||||
        try:
 | 
			
		||||
            self.domain.reboot()
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMError(
 | 
			
		||||
                f'Cannot reboot vm={self.domain_name}: {err}') from err
 | 
			
		||||
 | 
			
		||||
    def set_autostart(self, enable: bool) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Configure VM to be automatically started when the host machine boots.
 | 
			
		||||
        """
 | 
			
		||||
        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.domain_name} '
 | 
			
		||||
                          f'autostart={autostart_flag}: {err}') from err
 | 
			
		||||
 | 
			
		||||
    def set_vcpus(self, nvcpus: int, hotplug: bool = False):
 | 
			
		||||
        """
 | 
			
		||||
        Set vCPUs for VM. If `hotplug` is True set vCPUs on running VM.
 | 
			
		||||
        If VM is not running set `hotplug` to False. If `hotplug` is True
 | 
			
		||||
        and VM is not currently running vCPUs will set in config and will
 | 
			
		||||
        applied when machine boot.
 | 
			
		||||
 | 
			
		||||
        NB: Note that if this call is executed before the guest has
 | 
			
		||||
        finished booting, the guest may fail to process the change.
 | 
			
		||||
        """
 | 
			
		||||
        if nvcpus == 0:
 | 
			
		||||
            raise VMError(f'Cannot set zero vCPUs vm={self.domain_name}')
 | 
			
		||||
        if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
 | 
			
		||||
            flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
 | 
			
		||||
                     libvirt.VIR_DOMAIN_AFFECT_CONFIG)
 | 
			
		||||
        else:
 | 
			
		||||
            flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
			
		||||
        try:
 | 
			
		||||
            self.domain.setVcpusFlags(nvcpus, flags=flags)
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMError(
 | 
			
		||||
                f'Cannot set vCPUs for vm={self.domain_name}: {err}') from err
 | 
			
		||||
 | 
			
		||||
    def set_memory(self, memory: int, hotplug: bool = False):
 | 
			
		||||
        """
 | 
			
		||||
        Set momory for VM. `memory` must be passed in mebibytes. Internally
 | 
			
		||||
        converted to kibibytes. If `hotplug` is True set memory for running
 | 
			
		||||
        VM, else set memory in config and will applied when machine boot.
 | 
			
		||||
        If `hotplug` is True and machine is not currently running set memory
 | 
			
		||||
        in config.
 | 
			
		||||
        """
 | 
			
		||||
        if memory == 0:
 | 
			
		||||
            raise VMError(f'Cannot set zero memory vm={self.domain_name}')
 | 
			
		||||
        if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
 | 
			
		||||
            flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
 | 
			
		||||
                     libvirt.VIR_DOMAIN_AFFECT_CONFIG)
 | 
			
		||||
        else:
 | 
			
		||||
            flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
			
		||||
        try:
 | 
			
		||||
            self.domain.setMemoryFlags(memory * 1024,
 | 
			
		||||
                                       libvirt.VIR_DOMAIN_MEM_MAXIMUM)
 | 
			
		||||
            self.domain.setMemoryFlags(memory * 1024, flags=flags)
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMError(
 | 
			
		||||
                f'Cannot set memory for vm={self.domain_name} {memory=}: {err}') from err
 | 
			
		||||
 | 
			
		||||
    def attach_device(self, device_info: 'DeviceInfo', hotplug: bool = False):
 | 
			
		||||
        if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
 | 
			
		||||
            flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
 | 
			
		||||
                     libvirt.VIR_DOMAIN_AFFECT_CONFIG)
 | 
			
		||||
        else:
 | 
			
		||||
            flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
			
		||||
        self.domain.attachDeviceFlags(device_info.to_xml(), flags=flags)
 | 
			
		||||
 | 
			
		||||
    def detach_device(self, device_info: 'DeviceInfo', hotplug: bool = False):
 | 
			
		||||
        if hotplug and self.domain_info['state'] == libvirt.VIR_DOMAIN_RUNNING:
 | 
			
		||||
            flags = (libvirt.VIR_DOMAIN_AFFECT_LIVE |
 | 
			
		||||
                     libvirt.VIR_DOMAIN_AFFECT_CONFIG)
 | 
			
		||||
        else:
 | 
			
		||||
            flags = libvirt.VIR_DOMAIN_AFFECT_CONFIG
 | 
			
		||||
        self.domain.detachDeviceFlags(device_info.to_xml(), flags=flags)
 | 
			
		||||
 | 
			
		||||
    def resize_volume(self, vol_info: VolumeInfo, online: bool = False):
 | 
			
		||||
        # Этот метод должен принимать описание волюма и в зависимости от
 | 
			
		||||
        # флага online вызывать virStorageVolResize или virDomainBlockResize
 | 
			
		||||
        # https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainBlockResize
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def list_ssh_keys(self, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def set_ssh_keys(self, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def remove_ssh_keys(self, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def set_user_password(self, user: str, password: str) -> None:
 | 
			
		||||
        self.domain.setUserPassword(user, password)
 | 
			
		||||
 | 
			
		||||
    def dump_xml(self) -> str:
 | 
			
		||||
        return self.domain.XMLDesc()
 | 
			
		||||
 | 
			
		||||
    def delete(self, delete_volumes: bool = False) -> None:
 | 
			
		||||
        """Undefine VM."""
 | 
			
		||||
        self.shutdown(method='SIGTERM')
 | 
			
		||||
        self.domain.undefine()
 | 
			
		||||
        # todo: delete local volumes
 | 
			
		||||
							
								
								
									
										2
									
								
								computelib/volume/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								computelib/volume/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
from .storage_pool import StoragePool
 | 
			
		||||
from .volume import DiskInfo, Volume, VolumeInfo
 | 
			
		||||
							
								
								
									
										70
									
								
								computelib/volume/storage_pool.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										70
									
								
								computelib/volume/storage_pool.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,70 @@
 | 
			
		||||
import logging
 | 
			
		||||
from collections import namedtuple
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
from lxml import etree
 | 
			
		||||
 | 
			
		||||
from ..exceptions import StoragePoolError
 | 
			
		||||
from .volume import Volume, VolumeInfo
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class StoragePool:
 | 
			
		||||
    def __init__(self, pool: libvirt.virStoragePool):
 | 
			
		||||
        self.pool = pool
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        return self.pool.name()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def path(self) -> str:
 | 
			
		||||
        xml = etree.fromstring(self.pool.XMLDesc())
 | 
			
		||||
        return xml.xpath('/pool/target/path/text()')[0]
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def usage(self) -> 'StoragePoolUsage':
 | 
			
		||||
        xml = etree.fromstring(self.pool.XMLDesc())
 | 
			
		||||
        StoragePoolUsage = namedtuple('StoagePoolUsage',
 | 
			
		||||
                                      ['capacity', 'allocation', 'available'])
 | 
			
		||||
        return StoragePoolUsage(
 | 
			
		||||
            capacity=int(xml.xpath('/pool/capacity/text()')[0])
 | 
			
		||||
            allocation=int(xml.xpath('/pool/allocation/text()')[0])
 | 
			
		||||
            available=int(xml.xpath('/pool/available/text()')[0]))
 | 
			
		||||
 | 
			
		||||
    def dump_xml(self) -> str:
 | 
			
		||||
        return self.pool.XMLDesc()
 | 
			
		||||
 | 
			
		||||
    def refresh(self) -> None:
 | 
			
		||||
        self.pool.refresh()
 | 
			
		||||
 | 
			
		||||
    def create_volume(self, vol_info: VolumeInfo) -> Volume:
 | 
			
		||||
        """
 | 
			
		||||
        Create storage volume and return Volume instance.
 | 
			
		||||
        """
 | 
			
		||||
        logger.info('Create storage volume vol=%s in pool=%s',
 | 
			
		||||
                    vol_info.name, self.pool)
 | 
			
		||||
        vol = self.pool.createXML(
 | 
			
		||||
            vol_info.to_xml(),
 | 
			
		||||
            flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA)
 | 
			
		||||
        return Volume(self.pool, vol)
 | 
			
		||||
 | 
			
		||||
    def get_volume(self, name: str) -> Volume | None:
 | 
			
		||||
        """Lookup and return Volume instance or None."""
 | 
			
		||||
        logger.info('Lookup for storage volume vol=%s in pool=%s',
 | 
			
		||||
                    name, self.pool.name)
 | 
			
		||||
        try:
 | 
			
		||||
            vol = self.pool.storageVolLookupByName(name)
 | 
			
		||||
            return Volume(self.pool, vol)
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            if (err.get_error_domain() == libvirt.VIR_FROM_STORAGE or
 | 
			
		||||
                    err.get_error_code() == libvirt.VIR_ERR_NO_STORAGE_VOL):
 | 
			
		||||
                logger.error(err.get_error_message())
 | 
			
		||||
                return None
 | 
			
		||||
            logger.error('libvirt error: %s' err)
 | 
			
		||||
            raise StoragePoolError(f'libvirt error: {err}') from err
 | 
			
		||||
 | 
			
		||||
    def list_volumes(self) -> list[Volume]:
 | 
			
		||||
        return [Volume(self.pool, vol) for vol in self.pool.listAllVolumes()]
 | 
			
		||||
							
								
								
									
										80
									
								
								computelib/volume/volume.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										80
									
								
								computelib/volume/volume.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,80 @@
 | 
			
		||||
from dataclasses import dataclass
 | 
			
		||||
from time import time
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
from lxml import etree
 | 
			
		||||
from lxml.builder import E
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class VolumeInfo:
 | 
			
		||||
    name: str
 | 
			
		||||
    path: str
 | 
			
		||||
    capacity: int
 | 
			
		||||
 | 
			
		||||
    def to_xml(self) -> str:
 | 
			
		||||
        unixtime = str(int(time()))
 | 
			
		||||
        xml = E.volume(type='file')
 | 
			
		||||
        xml.append(E.name(self.name))
 | 
			
		||||
        xml.append(E.key(self.path))
 | 
			
		||||
        xml.append(E.source())
 | 
			
		||||
        xml.append(E.capacity(str(self.capacity * 1024 * 1024), unit='bytes'))
 | 
			
		||||
        xml.append(E.allocation('0'))
 | 
			
		||||
        xml.append(E.target(
 | 
			
		||||
            E.path(self.path),
 | 
			
		||||
            E.format(type='qcow2'),
 | 
			
		||||
            E.timestamps(
 | 
			
		||||
                E.atime(unixtime),
 | 
			
		||||
                E.mtime(unixtime),
 | 
			
		||||
                E.ctime(unixtime)),
 | 
			
		||||
            E.compat('1.1'),
 | 
			
		||||
            E.features(E.lazy_refcounts())
 | 
			
		||||
        ))
 | 
			
		||||
        return etree.tostring(xml, encoding='unicode', pretty_print=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
@dataclass
 | 
			
		||||
class DiskInfo:
 | 
			
		||||
    target: str
 | 
			
		||||
    path: str
 | 
			
		||||
    readonly: bool = False
 | 
			
		||||
 | 
			
		||||
    def to_xml(self) -> str:
 | 
			
		||||
        xml = E.disk(type='file', device='disk')
 | 
			
		||||
        xml.append(E.driver(name='qemu', type='qcow2', cache='writethrough'))
 | 
			
		||||
        xml.append(E.source(file=self.path))
 | 
			
		||||
        xml.append(E.target(dev=self.target, bus='virtio'))
 | 
			
		||||
        if self.readonly:
 | 
			
		||||
            xml.append(E.readonly())
 | 
			
		||||
        return etree.tostring(xml, encoding='unicode', pretty_print=True)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Volume:
 | 
			
		||||
    def __init__(self, pool: libvirt.virStoragePool,
 | 
			
		||||
                 vol: libvirt.virStorageVol):
 | 
			
		||||
        self.pool = pool
 | 
			
		||||
        self.vol = vol
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def name(self) -> str:
 | 
			
		||||
        return self.vol.name()
 | 
			
		||||
 | 
			
		||||
    @property
 | 
			
		||||
    def path(self) -> str:
 | 
			
		||||
        return self.vol.path()
 | 
			
		||||
 | 
			
		||||
    def dump_xml(self) -> str:
 | 
			
		||||
        return self.vol.XMLDesc()
 | 
			
		||||
 | 
			
		||||
    def clone(self, vol_info: VolumeInfo) -> None:
 | 
			
		||||
        self.pool.createXMLFrom(
 | 
			
		||||
            vol_info.to_xml(),
 | 
			
		||||
            self.vol,
 | 
			
		||||
            flags=libvirt.VIR_STORAGE_VOL_CREATE_PREALLOC_METADATA)
 | 
			
		||||
 | 
			
		||||
    def resize(self, capacity: int):
 | 
			
		||||
        """Resize volume to `capacity`. Unit is mebibyte."""
 | 
			
		||||
        self.vol.resize(capacity * 1024 * 1024)
 | 
			
		||||
 | 
			
		||||
    def delete(self) -> None:
 | 
			
		||||
        self.vol.delete()
 | 
			
		||||
		Reference in New Issue
	
	Block a user