cool updates
This commit is contained in:
		@@ -1 +1,4 @@
 | 
			
		||||
from .main import NodeAgent
 | 
			
		||||
from .main import LibvirtSession
 | 
			
		||||
from .vm import VirtualMachine, QemuAgent
 | 
			
		||||
from .config import ConfigLoader
 | 
			
		||||
from .exceptions import *
 | 
			
		||||
 
 | 
			
		||||
@@ -1,7 +0,0 @@
 | 
			
		||||
import libvirt
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NodeAgentBase:
 | 
			
		||||
    def __init__(self, conn: libvirt.virConnect, config: dict):
 | 
			
		||||
        self.config = config
 | 
			
		||||
        self.conn = conn
 | 
			
		||||
@@ -1,21 +1,29 @@
 | 
			
		||||
import os
 | 
			
		||||
import sys
 | 
			
		||||
import pathlib
 | 
			
		||||
import tomllib
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from collections import UserDict
 | 
			
		||||
 | 
			
		||||
from .exceptions import ConfigLoadError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
NODEAGENT_CONFIG_FILE = \
 | 
			
		||||
    os.getenv('NODEAGENT_CONFIG_FILE') or '/etc/nodeagent/configuration.toml'
 | 
			
		||||
NODEAGENT_CONFIG_FILE = os.getenv('NODEAGENT_CONFIG_FILE')
 | 
			
		||||
NODEAGENT_DEFAULT_CONFIG_FILE = '/etc/node-agent/config.toml'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def load_config(config: pathlib.Path):
 | 
			
		||||
    try:
 | 
			
		||||
        with open(config, 'rb') as conf:
 | 
			
		||||
            return tomllib.load(conf)
 | 
			
		||||
    except (OSError, ValueError) as readerr:
 | 
			
		||||
        sys.exit(f'Error: Cannot read configuration file: {readerr}')
 | 
			
		||||
    except tomllib.TOMLDecodeError as tomlerr:
 | 
			
		||||
        sys.exit(f'Error: Bad TOML syntax in configuration file: {tomlerr}')
 | 
			
		||||
class ConfigLoader(UserDict):
 | 
			
		||||
    def __init__(self, file: Path | None = None):
 | 
			
		||||
        if file is None:
 | 
			
		||||
            file = NODEAGENT_CONFIG_FILE or NODEAGENT_DEFAULT_CONFIG_FILE
 | 
			
		||||
        self.file = Path(file)
 | 
			
		||||
        self.data = self._load()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
config = load_config(pathlib.Path(NODEAGENT_CONFIG_FILE))
 | 
			
		||||
    def _load(self):
 | 
			
		||||
        try:
 | 
			
		||||
            with open(self.file, 'rb') as config:
 | 
			
		||||
                return tomllib.load(config)
 | 
			
		||||
                # todo: schema validation
 | 
			
		||||
        except (OSError, ValueError) as readerr:
 | 
			
		||||
            raise ConfigLoadError('Cannot read config file: %s: %s', (self.file, readerr)) from readerr
 | 
			
		||||
        except tomllib.TOMLDecodeError as tomlerr:
 | 
			
		||||
            raise ConfigLoadError('Bad TOML syntax in config file: %s: %s', (self.file, tomlerr)) from tomlerr
 | 
			
		||||
 
 | 
			
		||||
@@ -1,3 +1,15 @@
 | 
			
		||||
class ConfigLoadError(Exception):
 | 
			
		||||
    """Bad config file syntax, unreachable file or bad data."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class LibvirtSessionError(Exception):
 | 
			
		||||
    """Something went wrong while connecting to libvirt."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VMError(Exception):
 | 
			
		||||
    """Something went wrong while interacting with the domain."""
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VMNotFound(Exception):
 | 
			
		||||
    def __init__(self, domain, message='VM not found: {domain}'):
 | 
			
		||||
        self.domain = domain
 | 
			
		||||
@@ -5,30 +17,5 @@ class VMNotFound(Exception):
 | 
			
		||||
        super().__init__(self.message)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VMStartError(Exception):
 | 
			
		||||
    def __init__(self, domain, message='VM start error: {domain}'):
 | 
			
		||||
        self.domain = domain
 | 
			
		||||
        self.message = message.format(domain=domain)
 | 
			
		||||
        super().__init__(self.message)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VMShutdownError(Exception):
 | 
			
		||||
    def __init__(
 | 
			
		||||
            self,
 | 
			
		||||
            domain,
 | 
			
		||||
            message="VM '{domain}' cannot shutdown, try with hard=True"
 | 
			
		||||
        ):
 | 
			
		||||
        self.domain = domain
 | 
			
		||||
        self.message = message.format(domain=domain)
 | 
			
		||||
        super().__init__(self.message)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VMRebootError(Exception):
 | 
			
		||||
    def __init__(
 | 
			
		||||
            self,
 | 
			
		||||
            domain,
 | 
			
		||||
            message="VM '{domain}' reboot, try with hard=True",
 | 
			
		||||
        ):
 | 
			
		||||
        self.domain = domain
 | 
			
		||||
        self.message = message.format(domain=domain)
 | 
			
		||||
        super().__init__(self.message)
 | 
			
		||||
class QemuAgentError(Exception):
 | 
			
		||||
    """Mostly QEMU Guest Agent is not responding."""
 | 
			
		||||
 
 | 
			
		||||
@@ -1,8 +1,30 @@
 | 
			
		||||
from pathlib import Path
 | 
			
		||||
from contextlib import AbstractContextManager
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
 | 
			
		||||
from .vm import VirtualMachine
 | 
			
		||||
from .config import ConfigLoader
 | 
			
		||||
from .exceptions import LibvirtSessionError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NodeAgent:
 | 
			
		||||
    def __init__(self, conn: libvirt.virConnect, config: dict):
 | 
			
		||||
        self.vm = VirtualMachine(conn, config)
 | 
			
		||||
class LibvirtSession(AbstractContextManager):
 | 
			
		||||
    def __init__(self, config: Path | None = None):
 | 
			
		||||
        self.config = ConfigLoader(config)
 | 
			
		||||
        self.session = self._connect(self.config['libvirt']['uri'])
 | 
			
		||||
 | 
			
		||||
    def __enter__(self):
 | 
			
		||||
        return self
 | 
			
		||||
 | 
			
		||||
    def __exit__(self, exception_type, exception_value, exception_traceback):
 | 
			
		||||
        self.close()
 | 
			
		||||
 | 
			
		||||
    def _connect(self, connection_uri: str):
 | 
			
		||||
        try:
 | 
			
		||||
            return libvirt.open(connection_uri)
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise LibvirtSessionError(
 | 
			
		||||
                'Failed to open connection to the hypervisor: %s' % err
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def close(self) -> None:
 | 
			
		||||
        self.session.close()
 | 
			
		||||
 
 | 
			
		||||
							
								
								
									
										67
									
								
								node_agent/utils/vmctl.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										67
									
								
								node_agent/utils/vmctl.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,67 @@
 | 
			
		||||
"""
 | 
			
		||||
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> [-f|--force] [-9|--sigkill]
 | 
			
		||||
 | 
			
		||||
Options:
 | 
			
		||||
    -c, --config <file>  Config file [default: /etc/node-agent/config.yaml]
 | 
			
		||||
    -l, --loglvl <lvl>   Logging level [default: INFO]
 | 
			
		||||
    -f, --force          Force action. On shutdown calls graceful destroy()
 | 
			
		||||
    -9, --sigkill        Send SIGKILL to QEMU process. Not affects without --force
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import sys
 | 
			
		||||
import pathlib
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from docopt import docopt
 | 
			
		||||
 | 
			
		||||
sys.path.append('/home/ge/Code/node-agent')
 | 
			
		||||
from node_agent import LibvirtSession, VirtualMachine, VMError, VMNotFound
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
levels = logging.getLevelNamesMapping()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Color:
 | 
			
		||||
    RED = '\033[31m'
 | 
			
		||||
    GREEN = '\033[32m'
 | 
			
		||||
    YELLOW = '\033[33m'
 | 
			
		||||
    NONE = '\033[0m'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cli():
 | 
			
		||||
    args = docopt(__doc__)
 | 
			
		||||
    config = pathlib.Path(args['--config']) or None
 | 
			
		||||
    loglvl = args['--loglvl'].upper()
 | 
			
		||||
 | 
			
		||||
    if loglvl in levels:
 | 
			
		||||
        logging.basicConfig(level=levels[loglvl])
 | 
			
		||||
 | 
			
		||||
    with LibvirtSession(config) as session:
 | 
			
		||||
        try:
 | 
			
		||||
            vm = VirtualMachine(session, args['<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(force=args['--force'], sigkill=args['sigkill'])
 | 
			
		||||
        except VMNotFound as nferr:
 | 
			
		||||
            sys.exit(f'{Color.RED}VM {args["<machine>"]} not found.{Color.NONE}')
 | 
			
		||||
        except VMError as vmerr:
 | 
			
		||||
            sys.exit(f'{Color.RED}{vmerr}{Color.NONE}')
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    cli()
 | 
			
		||||
							
								
								
									
										92
									
								
								node_agent/utils/vmexec.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										92
									
								
								node_agent/utils/vmexec.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,92 @@
 | 
			
		||||
"""
 | 
			
		||||
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 [default: INFO]
 | 
			
		||||
    -s, --shell <shell>  Guest shell [default: /bin/sh]
 | 
			
		||||
    -t, --timeout <sec>  QEMU timeout in seconds to stop polling command status [default: 60]
 | 
			
		||||
"""
 | 
			
		||||
 | 
			
		||||
import sys
 | 
			
		||||
import pathlib
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
from docopt import docopt
 | 
			
		||||
 | 
			
		||||
sys.path.append('/home/ge/Code/node-agent')
 | 
			
		||||
from node_agent import LibvirtSession, VMNotFound, QemuAgent, QemuAgentError
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
levels = logging.getLevelNamesMapping()
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class Color:
 | 
			
		||||
    RED = '\033[31m'
 | 
			
		||||
    GREEN = '\033[32m'
 | 
			
		||||
    YELLOW = '\033[33m'
 | 
			
		||||
    NONE = '\033[0m'
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
def cli():
 | 
			
		||||
    args = docopt(__doc__)
 | 
			
		||||
    config = pathlib.Path(args['--config']) or None
 | 
			
		||||
    loglvl = args['--loglvl'].upper()
 | 
			
		||||
 | 
			
		||||
    if loglvl in levels:
 | 
			
		||||
        logging.basicConfig(level=levels[loglvl])
 | 
			
		||||
 | 
			
		||||
    with LibvirtSession(config) as session:
 | 
			
		||||
        shell = args['--shell']
 | 
			
		||||
        cmd = args['<command>']
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            ga = QemuAgent(session, args['<machine>'])
 | 
			
		||||
            exited, exitcode, stdout, stderr = ga.shellexec(
 | 
			
		||||
                cmd,
 | 
			
		||||
                executable=shell,
 | 
			
		||||
                capture_output=True,
 | 
			
		||||
                decode_output=True,
 | 
			
		||||
                timeout=int(args['--timeout']),
 | 
			
		||||
            )
 | 
			
		||||
        except QemuAgentError as qemuerr:
 | 
			
		||||
            errmsg = f'{Color.RED}{err}{Color.NONE}'
 | 
			
		||||
            if str(err).startswith('Polling command pid='):
 | 
			
		||||
                errmsg = (
 | 
			
		||||
                    errmsg + Color.YELLOW
 | 
			
		||||
                    + '\n[NOTE: command may still running]'
 | 
			
		||||
                    + Color.NONE
 | 
			
		||||
                )
 | 
			
		||||
            sys.exit(errmsg)
 | 
			
		||||
        except VMNotFound as err:
 | 
			
		||||
            sys.exit(
 | 
			
		||||
                f'{Color.RED}VM {args["<machine>"]} not found.{Color.NONE}'
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    if not exited:
 | 
			
		||||
        print(
 | 
			
		||||
            Color.YELLOW
 | 
			
		||||
            +'[NOTE: command may still running]'
 | 
			
		||||
            + Color.NONE
 | 
			
		||||
        )
 | 
			
		||||
    else:
 | 
			
		||||
        if exitcode == 0:
 | 
			
		||||
            exitcolor = Color.GREEN
 | 
			
		||||
        else:
 | 
			
		||||
            exitcolor = Color.RED
 | 
			
		||||
        print(
 | 
			
		||||
            exitcolor
 | 
			
		||||
            + f'[command exited with exit code {exitcode}]'
 | 
			
		||||
            + Color.NONE
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    if stderr:
 | 
			
		||||
        print(Color.RED + stderr.strip() + Color.NONE)
 | 
			
		||||
    if stdout:
 | 
			
		||||
        print(Color.GREEN + stdout.strip() + Color.NONE)
 | 
			
		||||
 | 
			
		||||
if __name__ == '__main__':
 | 
			
		||||
    cli()
 | 
			
		||||
							
								
								
									
										0
									
								
								node_agent/utils/volctl.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								node_agent/utils/volctl.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										120
									
								
								node_agent/vm.py
									
									
									
									
									
								
							
							
						
						
									
										120
									
								
								node_agent/vm.py
									
									
									
									
									
								
							@@ -1,120 +0,0 @@
 | 
			
		||||
import libvirt
 | 
			
		||||
 | 
			
		||||
from .base import NodeAgentBase
 | 
			
		||||
from .exceptions import (
 | 
			
		||||
    VMNotFound,
 | 
			
		||||
    VMStartError,
 | 
			
		||||
    VMRebootError,
 | 
			
		||||
    VMShutdownError,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VirtualMachine(NodeAgentBase):
 | 
			
		||||
 | 
			
		||||
    def _dom(self, domain: str) -> libvirt.virDomain:
 | 
			
		||||
        """Get virDomain object to manipulate with domain."""
 | 
			
		||||
        try:
 | 
			
		||||
            ret = self.conn.lookupByName(domain)
 | 
			
		||||
            if ret is not None:
 | 
			
		||||
                return ret
 | 
			
		||||
            raise VMNotFound(domain)
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMNotFound(err) from err
 | 
			
		||||
 | 
			
		||||
    def create(
 | 
			
		||||
        self,
 | 
			
		||||
        name: str,
 | 
			
		||||
        volumes: list[dict],
 | 
			
		||||
        vcpus: int,
 | 
			
		||||
        vram: int,
 | 
			
		||||
        image: dict,
 | 
			
		||||
        cdrom: dict | None = None,
 | 
			
		||||
    ):
 | 
			
		||||
        # TODO
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def delete(self, name: str, delete_volumes=False):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def status(self, name: str) -> str:
 | 
			
		||||
        """
 | 
			
		||||
        Return VM state: 'running', 'shutoff', etc. Ref:
 | 
			
		||||
        https://libvirt.org/html/libvirt-libvirt-domain.html#virDomainState
 | 
			
		||||
        """
 | 
			
		||||
        state = self._dom(name).info()[0]
 | 
			
		||||
        match state:
 | 
			
		||||
            case libvirt.VIR_DOMAIN_NOSTATE:
 | 
			
		||||
                return 'nostate'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_RUNNING:
 | 
			
		||||
                return 'running'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_BLOCKED:
 | 
			
		||||
                return 'blocked'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_PAUSED:
 | 
			
		||||
                return 'paused'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_SHUTDOWN:
 | 
			
		||||
                return 'shutdown'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_SHUTOFF:
 | 
			
		||||
                return 'shutoff'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_CRASHED:
 | 
			
		||||
                return 'crashed'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_PMSUSPENDED:
 | 
			
		||||
                return 'pmsuspended'
 | 
			
		||||
 | 
			
		||||
    def is_running(self, name: str) -> bool:
 | 
			
		||||
        """Return True if VM is running, else return False."""
 | 
			
		||||
        if self._dom(name).isActive() != 1:
 | 
			
		||||
            return False  # inactive (0) or error (-1)
 | 
			
		||||
        return True
 | 
			
		||||
 | 
			
		||||
    def start(self, name: str) -> None:
 | 
			
		||||
        """Start VM."""
 | 
			
		||||
        if not self.is_running(name):
 | 
			
		||||
            ret = self._dom(name).create()
 | 
			
		||||
        else:
 | 
			
		||||
            return
 | 
			
		||||
        if ret != 0:
 | 
			
		||||
            raise VMStartError(name)
 | 
			
		||||
 | 
			
		||||
    def shutdown(self, name: str, hard=False) -> None:
 | 
			
		||||
        """Shutdown VM. Use hard=True to force shutdown."""
 | 
			
		||||
        if hard:
 | 
			
		||||
            # Destroy VM gracefully (no SIGKILL)
 | 
			
		||||
            ret = self._dom(name).destroyFlags(flags=libvirt.VIR_DOMAIN_DESTROY_GRACEFUL)
 | 
			
		||||
        else:
 | 
			
		||||
            # Normal VM shutdown, OS may ignore this.
 | 
			
		||||
            ret = self._dom(name).shutdown()
 | 
			
		||||
        if ret != 0:
 | 
			
		||||
            raise VMShutdownError(name)
 | 
			
		||||
 | 
			
		||||
    def reboot(self, name: str, hard=False) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Reboot VM. Use hard=True to force reboot. With forced reboot
 | 
			
		||||
        VM will shutdown via self.shutdown() (no forced) and started.
 | 
			
		||||
        """
 | 
			
		||||
        if hard:
 | 
			
		||||
            # Forced "reboot"
 | 
			
		||||
            self.shutdown(name)
 | 
			
		||||
            self.start(name)
 | 
			
		||||
        else:
 | 
			
		||||
            # Normal reboot.
 | 
			
		||||
            ret = self._dom(name).reboot()
 | 
			
		||||
        if ret != 0:
 | 
			
		||||
            raise VMRebootError(name)
 | 
			
		||||
 | 
			
		||||
    def vcpu_set(self, name: str, count: int):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def vram_set(self, name: str, count: int):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def ssh_keys_list(self, name: str, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def ssh_keys_add(self, name: str, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def ssh_keys_remove(self, name: str, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def set_user_password(self, name: str, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
							
								
								
									
										2
									
								
								node_agent/vm/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										2
									
								
								node_agent/vm/__init__.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,2 @@
 | 
			
		||||
from .main import VirtualMachine
 | 
			
		||||
from .ga import QemuAgent
 | 
			
		||||
							
								
								
									
										22
									
								
								node_agent/vm/base.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										22
									
								
								node_agent/vm/base.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,22 @@
 | 
			
		||||
import libvirt
 | 
			
		||||
 | 
			
		||||
from ..main import LibvirtSession
 | 
			
		||||
from ..exceptions import VMNotFound
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VMBase:
 | 
			
		||||
    def __init__(self, session: LibvirtSession, name: str):
 | 
			
		||||
        self.domname = name
 | 
			
		||||
        self.session = session.session  # virConnect object
 | 
			
		||||
        self.config = session.config  # ConfigLoader object
 | 
			
		||||
        self.domain = self._get_domain(name)
 | 
			
		||||
 | 
			
		||||
    def _get_domain(self, name: str) -> libvirt.virDomain:
 | 
			
		||||
        """Get virDomain object by name to manipulate with domain."""
 | 
			
		||||
        try:
 | 
			
		||||
            domain = self.session.lookupByName(name)
 | 
			
		||||
            if domain is not None:
 | 
			
		||||
                return domain
 | 
			
		||||
            raise VMNotFound(name)
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMNotFound(err) from err
 | 
			
		||||
							
								
								
									
										192
									
								
								node_agent/vm/ga.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										192
									
								
								node_agent/vm/ga.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,192 @@
 | 
			
		||||
import json
 | 
			
		||||
import logging
 | 
			
		||||
from time import time, sleep
 | 
			
		||||
from base64 import standard_b64encode, b64decode, b64encode
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
import libvirt_qemu
 | 
			
		||||
 | 
			
		||||
from ..main import LibvirtSession
 | 
			
		||||
from ..exceptions import QemuAgentError
 | 
			
		||||
from .base import VMBase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
DEFAULT_WAIT_TIMEOUT = 60  # seconds
 | 
			
		||||
POLL_INTERVAL = 0.3
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class QemuAgent(VMBase):
 | 
			
		||||
    """
 | 
			
		||||
    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.
 | 
			
		||||
    _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 on command exited or on timeout.
 | 
			
		||||
    _return_tuple()
 | 
			
		||||
        This method transforms JSON command output to tuple and decode
 | 
			
		||||
        base64 encoded strings optionally.
 | 
			
		||||
    """
 | 
			
		||||
 | 
			
		||||
    def __init__(self,
 | 
			
		||||
        session: LibvirtSession,
 | 
			
		||||
        name: str,
 | 
			
		||||
        timeout: int | None = None,
 | 
			
		||||
        flags: int | None = None
 | 
			
		||||
    ):
 | 
			
		||||
        super().__init__(session, name)
 | 
			
		||||
        self.timeout = timeout or DEFAULT_WAIT_TIMEOUT  # timeout for guest agent
 | 
			
		||||
        self.flags = flags or libvirt_qemu.VIR_DOMAIN_QEMU_MONITOR_COMMAND_DEFAULT
 | 
			
		||||
 | 
			
		||||
    def execute(
 | 
			
		||||
        self,
 | 
			
		||||
        command: dict,
 | 
			
		||||
        stdin: str | None = None,
 | 
			
		||||
        capture_output: bool = False,
 | 
			
		||||
        decode_output: bool = False,
 | 
			
		||||
        wait: bool = True,
 | 
			
		||||
        timeout: int = DEFAULT_WAIT_TIMEOUT,
 | 
			
		||||
    ):
 | 
			
		||||
        """
 | 
			
		||||
        Execute command on guest and return output if capture_output is True.
 | 
			
		||||
        See https://wiki.qemu.org/Documentation/QMP for QEMU commands reference.
 | 
			
		||||
        Return values:
 | 
			
		||||
            tuple(
 | 
			
		||||
                exited: bool | None,
 | 
			
		||||
                exitcode: int | None,
 | 
			
		||||
                stdout: str | None,
 | 
			
		||||
                stderr: str | None
 | 
			
		||||
            )
 | 
			
		||||
        stdout and stderr are base64 encoded strings or None.
 | 
			
		||||
        """
 | 
			
		||||
        # 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:
 | 
			
		||||
            cmd_pid = json.loads(cmd_out)['return']['pid']
 | 
			
		||||
            return self._get_cmd_result(
 | 
			
		||||
                cmd_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 = DEFAULT_WAIT_TIMEOUT,
 | 
			
		||||
    ):
 | 
			
		||||
        """
 | 
			
		||||
        Execute command on guest with selected shell. /bin/sh by default.
 | 
			
		||||
        Otherwise of execute() this function brings 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 _execute(self, command: dict):
 | 
			
		||||
        logging.debug('Execute command: vm=%s cmd=%s', self.domname, command)
 | 
			
		||||
        try:
 | 
			
		||||
            return libvirt_qemu.qemuAgentCommand(
 | 
			
		||||
                self.domain,  # virDomain object
 | 
			
		||||
                json.dumps(command),
 | 
			
		||||
                self.timeout,
 | 
			
		||||
                self.flags,
 | 
			
		||||
            )
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise QemuAgentError(err) from err
 | 
			
		||||
 | 
			
		||||
    def _get_cmd_result(
 | 
			
		||||
        self,
 | 
			
		||||
        pid: int,
 | 
			
		||||
        decode_output: bool = False,
 | 
			
		||||
        wait: bool = True,
 | 
			
		||||
        timeout: int = DEFAULT_WAIT_TIMEOUT,
 | 
			
		||||
    ):
 | 
			
		||||
        """Get executed command result. See GuestAgent.execute() for info."""
 | 
			
		||||
        exited = exitcode = stdout = stderr = None
 | 
			
		||||
 | 
			
		||||
        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', pid)
 | 
			
		||||
        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 QemuAgentError(
 | 
			
		||||
                    f'Polling command pid={pid} took longer than {timeout} seconds.'
 | 
			
		||||
                )
 | 
			
		||||
        logger.debug(
 | 
			
		||||
            'Polling command pid=%s finished, time taken: %s seconds',
 | 
			
		||||
            pid, int(time()-start_time)
 | 
			
		||||
        )
 | 
			
		||||
        return self._return_tuple(output, decode=decode_output)
 | 
			
		||||
 | 
			
		||||
    def _return_tuple(self, cmd_output: dict, decode: bool = False):
 | 
			
		||||
        exited = cmd_output['return']['exited']
 | 
			
		||||
        exitcode = cmd_output['return']['exitcode']
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            stdout = cmd_output['return']['out-data']
 | 
			
		||||
            if decode and stdout:
 | 
			
		||||
                stdout = b64decode(stdout).decode('utf-8')
 | 
			
		||||
        except KeyError:
 | 
			
		||||
            stdout = None
 | 
			
		||||
 | 
			
		||||
        try:
 | 
			
		||||
            stderr = cmd_output['return']['err-data']
 | 
			
		||||
            if decode and stderr:
 | 
			
		||||
                stderr = b64decode(stderr).decode('utf-8')
 | 
			
		||||
        except KeyError:
 | 
			
		||||
            stderr = None
 | 
			
		||||
 | 
			
		||||
        return exited, exitcode, stdout, stderr
 | 
			
		||||
							
								
								
									
										126
									
								
								node_agent/vm/main.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										126
									
								
								node_agent/vm/main.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,126 @@
 | 
			
		||||
import logging
 | 
			
		||||
 | 
			
		||||
import libvirt
 | 
			
		||||
 | 
			
		||||
from ..exceptions import VMError
 | 
			
		||||
from .base import VMBase
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
logger = logging.getLogger(__name__)
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class VirtualMachine(VMBase):
 | 
			
		||||
 | 
			
		||||
    @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
 | 
			
		||||
        """
 | 
			
		||||
        state = self.domain.info()[0]
 | 
			
		||||
        match state:
 | 
			
		||||
            case libvirt.VIR_DOMAIN_NOSTATE:
 | 
			
		||||
                return 'nostate'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_RUNNING:
 | 
			
		||||
                return 'running'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_BLOCKED:
 | 
			
		||||
                return 'blocked'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_PAUSED:
 | 
			
		||||
                return 'paused'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_SHUTDOWN:
 | 
			
		||||
                return 'shutdown'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_SHUTOFF:
 | 
			
		||||
                return 'shutoff'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_CRASHED:
 | 
			
		||||
                return 'crashed'
 | 
			
		||||
            case libvirt.VIR_DOMAIN_PMSUSPENDED:
 | 
			
		||||
                return 'pmsuspended'
 | 
			
		||||
 | 
			
		||||
    @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
 | 
			
		||||
 | 
			
		||||
    def start(self) -> None:
 | 
			
		||||
        """Start defined VM."""
 | 
			
		||||
        logger.info('Starting VM: vm=%s', self.domname)
 | 
			
		||||
        if self.is_running:
 | 
			
		||||
            logger.debug('VM vm=%s is already started, nothing to do', self.domname)
 | 
			
		||||
            return
 | 
			
		||||
        try:
 | 
			
		||||
            ret = self.domain.create()
 | 
			
		||||
        except libvirt.libvirtError as err:
 | 
			
		||||
            raise VMError(err) from err
 | 
			
		||||
        if ret != 0:
 | 
			
		||||
            raise VMError('Cannot start VM: vm=%s exit_code=%s', self.domname, ret)
 | 
			
		||||
 | 
			
		||||
    def shutdown(self, force=False, sigkill=False) -> None:
 | 
			
		||||
        """
 | 
			
		||||
        Send ACPI signal to guest OS to shutdown. OS may ignore this.
 | 
			
		||||
        Use `force=True` for graceful VM destroy. Add `sigkill=True`
 | 
			
		||||
        to hard shutdown (may corrupt guest data!).
 | 
			
		||||
        """
 | 
			
		||||
        if sigkill:
 | 
			
		||||
            flags = libvirt.VIR_DOMAIN_DESTROY_DEFAULT
 | 
			
		||||
        else:
 | 
			
		||||
            flags = libvirt.VIR_DOMAIN_DESTROY_GRACEFUL
 | 
			
		||||
        if force:
 | 
			
		||||
            ret = self.domain.destroyFlags(flags=flags)
 | 
			
		||||
        else:
 | 
			
		||||
            # Normal VM shutdown via ACPI signal, OS may ignore this.
 | 
			
		||||
            ret = self.domain.shutdown()
 | 
			
		||||
        if ret != 0:
 | 
			
		||||
            raise VMError(
 | 
			
		||||
                f'Cannot shutdown VM, try force or sigkill: %s', self.domname
 | 
			
		||||
            )
 | 
			
		||||
 | 
			
		||||
    def reset(self):
 | 
			
		||||
        """
 | 
			
		||||
        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.
 | 
			
		||||
        """
 | 
			
		||||
        ret = self.domian.reset()
 | 
			
		||||
        if ret != 0:
 | 
			
		||||
            raise VMError('Cannot reset VM: %s', self.domname)
 | 
			
		||||
 | 
			
		||||
    def reboot(self) -> None:
 | 
			
		||||
        """Send ACPI signal to guest OS to reboot. OS may ignore this."""
 | 
			
		||||
        ret = self.domain.reboot()
 | 
			
		||||
        if ret != 0:
 | 
			
		||||
            raise VMError('Cannot reboot: %s', self.domname)
 | 
			
		||||
 | 
			
		||||
    def set_autostart(self) -> None:
 | 
			
		||||
        ret = self.domain.autostart()
 | 
			
		||||
        if ret != 0:
 | 
			
		||||
            raise VMError('Cannot set : %s', self.domname)
 | 
			
		||||
 | 
			
		||||
    def vcpu_set(self, count: int):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def vram_set(self, count: int):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def ssh_keys_list(self, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def ssh_keys_add(self, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def ssh_keys_remove(self, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
 | 
			
		||||
    def set_user_password(self, user: str):
 | 
			
		||||
        pass
 | 
			
		||||
							
								
								
									
										0
									
								
								node_agent/volume/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										0
									
								
								node_agent/volume/__init__.py
									
									
									
									
									
										Normal file
									
								
							
							
								
								
									
										115
									
								
								node_agent/xml.py
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										115
									
								
								node_agent/xml.py
									
									
									
									
									
										Normal file
									
								
							@@ -0,0 +1,115 @@
 | 
			
		||||
import pathlib
 | 
			
		||||
 | 
			
		||||
from lxml import etree
 | 
			
		||||
from lxml.builder import E
 | 
			
		||||
 | 
			
		||||
 | 
			
		||||
class NewXML:
 | 
			
		||||
    def __init__(
 | 
			
		||||
        self,
 | 
			
		||||
        name: str,
 | 
			
		||||
        title: str,
 | 
			
		||||
        memory: int,
 | 
			
		||||
        vcpus: int,
 | 
			
		||||
        cpu_vendor: str,
 | 
			
		||||
        cpu_model: str,
 | 
			
		||||
        volume_path: str,
 | 
			
		||||
 | 
			
		||||
        desc: str | None = None,
 | 
			
		||||
        show_boot_menu: bool = False,
 | 
			
		||||
    ):
 | 
			
		||||
        """
 | 
			
		||||
        Initialise basic XML using lxml E-Factory. Ref:
 | 
			
		||||
 | 
			
		||||
            - https://lxml.de/tutorial.html#the-e-factory
 | 
			
		||||
            - https://libvirt.org/formatdomain.html
 | 
			
		||||
        """
 | 
			
		||||
        DOMAIN = E.domain
 | 
			
		||||
        NAME = E.name
 | 
			
		||||
        TITLE = E.title
 | 
			
		||||
        DESCRIPTION = E.description
 | 
			
		||||
        METADATA = E.metadata
 | 
			
		||||
        MEMORY = E.memory
 | 
			
		||||
        CURRENTMEMORY = E.currentMemory
 | 
			
		||||
        VCPU = E.vcpu
 | 
			
		||||
        OS = E.os
 | 
			
		||||
        OS_TYPE = E.type
 | 
			
		||||
        OS_BOOT = E.boot
 | 
			
		||||
        FEATURES = E.features
 | 
			
		||||
        ACPI = E.acpi
 | 
			
		||||
        APIC = E.apic
 | 
			
		||||
        CPU = E.cpu
 | 
			
		||||
        CPU_VENDOR = E.vendor
 | 
			
		||||
        CPU_MODEL = E.model
 | 
			
		||||
        ON_POWEROFF = E.on_poweroff
 | 
			
		||||
        ON_REBOOT = E.on_reboot
 | 
			
		||||
        ON_CRASH = E.on_crash
 | 
			
		||||
        DEVICES = E.devices
 | 
			
		||||
        EMULATOR = E.emulator
 | 
			
		||||
        DISK = E.disk
 | 
			
		||||
        DISK_DRIVER = E.driver
 | 
			
		||||
        DISK_SOURCE = E.source
 | 
			
		||||
        DISK_TARGET = E.target
 | 
			
		||||
        INTERFACE = E.interface
 | 
			
		||||
        GRAPHICS = E.graphics
 | 
			
		||||
 | 
			
		||||
        self.domain = DOMAIN(
 | 
			
		||||
            NAME(name),
 | 
			
		||||
            TITLE(title),
 | 
			
		||||
            DESCRIPTION(desc or ""),
 | 
			
		||||
            METADATA(),
 | 
			
		||||
            MEMORY(str(memory), unit='MB'),
 | 
			
		||||
            CURRENTMEMORY(str(memory), unit='MB'),
 | 
			
		||||
            VCPU(str(vcpus), placement='static'),
 | 
			
		||||
            OS(
 | 
			
		||||
                OS_TYPE('hvm', arch='x86_64'),
 | 
			
		||||
                OS_BOOT(dev='cdrom'),
 | 
			
		||||
                OS_BOOT(dev='hd'),
 | 
			
		||||
            ),
 | 
			
		||||
            FEATURES(
 | 
			
		||||
                ACPI(),
 | 
			
		||||
                APIC(),
 | 
			
		||||
            ),
 | 
			
		||||
            CPU(
 | 
			
		||||
                CPU_VENDOR(cpu_vendor),
 | 
			
		||||
                CPU_MODEL(cpu_model, fallback='forbid'),
 | 
			
		||||
                mode='custom',
 | 
			
		||||
                match='exact',
 | 
			
		||||
                check='partial',
 | 
			
		||||
            ),
 | 
			
		||||
            ON_POWEROFF('destroy'),
 | 
			
		||||
            ON_REBOOT('restart'),
 | 
			
		||||
            ON_CRASH('restart'),
 | 
			
		||||
            DEVICES(
 | 
			
		||||
                EMULATOR('/usr/bin/qemu-system-x86_64'),
 | 
			
		||||
                DISK(
 | 
			
		||||
                    DISK_DRIVER(name='qemu', type='qcow2', cache='writethrough'),
 | 
			
		||||
                    DISK_SOURCE(file=volume_path),
 | 
			
		||||
                    DISK_TARGET(dev='vda', bus='virtio'),
 | 
			
		||||
                    type='file',
 | 
			
		||||
                    device='disk',
 | 
			
		||||
                ),
 | 
			
		||||
            ),
 | 
			
		||||
            type='kvm',
 | 
			
		||||
        )
 | 
			
		||||
 | 
			
		||||
    def add_volume(self, options: dict, params: dict):
 | 
			
		||||
        """Add disk device to domain."""
 | 
			
		||||
        DISK = E.disk
 | 
			
		||||
        DISK_DRIVER = E.driver
 | 
			
		||||
        DISK_SOURCE = E.source
 | 
			
		||||
        DISK_TARGET = E.target
 | 
			
		||||
 | 
			
		||||
x = NewXML(
 | 
			
		||||
    name='1',
 | 
			
		||||
    title='first',
 | 
			
		||||
    memory=2048,
 | 
			
		||||
    vcpus=4,
 | 
			
		||||
    cpu_vendor='Intel',
 | 
			
		||||
    cpu_model='Broadwell',
 | 
			
		||||
    volume_path='/srv/vm-volumes/5031077f-f9ea-410b-8d84-ae6e79f8cde0.qcow2',
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
# x.add_volume()
 | 
			
		||||
# print(x.domain)
 | 
			
		||||
print(etree.tostring(x.domain, pretty_print=True).decode().strip())
 | 
			
		||||
		Reference in New Issue
	
	Block a user