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