2023-11-06 12:52:19 +03:00
|
|
|
"""Command line interface."""
|
|
|
|
|
|
|
|
import argparse
|
2023-11-09 01:17:50 +03:00
|
|
|
import io
|
2023-11-06 12:52:19 +03:00
|
|
|
import logging
|
|
|
|
import os
|
|
|
|
import shlex
|
|
|
|
import sys
|
2023-11-09 01:17:50 +03:00
|
|
|
from collections import UserDict
|
|
|
|
from typing import Any
|
|
|
|
from uuid import uuid4
|
2023-11-06 12:52:19 +03:00
|
|
|
|
|
|
|
import libvirt
|
2023-11-09 01:17:50 +03:00
|
|
|
import yaml
|
|
|
|
from pydantic import ValidationError
|
2023-11-06 12:52:19 +03:00
|
|
|
|
|
|
|
from compute import __version__
|
|
|
|
from compute.exceptions import (
|
|
|
|
ComputeServiceError,
|
|
|
|
GuestAgentTimeoutExceededError,
|
|
|
|
)
|
|
|
|
from compute.instance import GuestAgent
|
|
|
|
from compute.session import Session
|
2023-11-09 01:17:50 +03:00
|
|
|
from compute.utils import ids
|
2023-11-06 12:52:19 +03:00
|
|
|
|
|
|
|
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
log_levels = logging.getLevelNamesMapping()
|
|
|
|
|
|
|
|
env_log_level = os.getenv('CMP_LOG')
|
|
|
|
|
|
|
|
libvirt.registerErrorHandler(
|
|
|
|
lambda userdata, err: None, # noqa: ARG005
|
|
|
|
ctx=None,
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
class Table:
|
|
|
|
"""Minimalistic text table constructor."""
|
|
|
|
|
|
|
|
def __init__(self, whitespace: str | None = None):
|
|
|
|
"""Initialise Table."""
|
|
|
|
self.whitespace = whitespace or '\t'
|
|
|
|
self.header = []
|
2023-11-09 01:17:50 +03:00
|
|
|
self.rows = []
|
|
|
|
self.table = ''
|
2023-11-06 12:52:19 +03:00
|
|
|
|
2023-11-09 01:17:50 +03:00
|
|
|
def add_row(self, row: list) -> None:
|
2023-11-06 12:52:19 +03:00
|
|
|
"""Add table row."""
|
2023-11-09 01:17:50 +03:00
|
|
|
self.rows.append([str(col) for col in row])
|
2023-11-06 12:52:19 +03:00
|
|
|
|
2023-11-09 01:17:50 +03:00
|
|
|
def add_rows(self, rows: list[list]) -> None:
|
2023-11-06 12:52:19 +03:00
|
|
|
"""Add multiple rows."""
|
|
|
|
for row in rows:
|
2023-11-09 01:17:50 +03:00
|
|
|
self.add_row(row)
|
2023-11-06 12:52:19 +03:00
|
|
|
|
|
|
|
def __str__(self) -> str:
|
|
|
|
"""Build table and return."""
|
2023-11-09 01:17:50 +03:00
|
|
|
widths = [max(map(len, col)) for col in zip(*self.rows, strict=True)]
|
|
|
|
self.rows.insert(0, [str(h).upper() for h in self.header])
|
|
|
|
for row in self.rows:
|
|
|
|
self.table += self.whitespace.join(
|
2023-11-06 12:52:19 +03:00
|
|
|
(
|
|
|
|
val.ljust(width)
|
|
|
|
for val, width in zip(row, widths, strict=True)
|
|
|
|
)
|
|
|
|
)
|
2023-11-09 01:17:50 +03:00
|
|
|
self.table += '\n'
|
|
|
|
return self.table.strip()
|
2023-11-06 12:52:19 +03:00
|
|
|
|
|
|
|
|
|
|
|
def _list_instances(session: Session) -> None:
|
|
|
|
table = Table()
|
|
|
|
table.header = ['NAME', 'STATE']
|
|
|
|
for instance in session.list_instances():
|
2023-11-09 01:17:50 +03:00
|
|
|
table.add_row(
|
2023-11-06 12:52:19 +03:00
|
|
|
[
|
|
|
|
instance.name,
|
2023-11-09 01:17:50 +03:00
|
|
|
instance.get_status(),
|
2023-11-06 12:52:19 +03:00
|
|
|
]
|
|
|
|
)
|
|
|
|
print(table)
|
|
|
|
sys.exit()
|
|
|
|
|
|
|
|
|
|
|
|
def _exec_guest_agent_command(
|
|
|
|
session: Session, args: argparse.Namespace
|
|
|
|
) -> None:
|
|
|
|
instance = session.get_instance(args.instance)
|
|
|
|
ga = GuestAgent(instance.domain, timeout=args.timeout)
|
|
|
|
arguments = args.arguments.copy()
|
|
|
|
if len(arguments) > 1:
|
|
|
|
arguments = [shlex.join(arguments)]
|
|
|
|
if not args.no_cmd_string:
|
|
|
|
arguments.insert(0, '-c')
|
|
|
|
stdin = None
|
|
|
|
if not sys.stdin.isatty():
|
|
|
|
stdin = sys.stdin.read()
|
|
|
|
try:
|
|
|
|
output = ga.guest_exec(
|
|
|
|
path=args.shell,
|
|
|
|
args=arguments,
|
|
|
|
env=args.env,
|
|
|
|
stdin=stdin,
|
|
|
|
capture_output=True,
|
|
|
|
decode_output=True,
|
|
|
|
poll=True,
|
|
|
|
)
|
|
|
|
except GuestAgentTimeoutExceededError as e:
|
|
|
|
sys.exit(
|
|
|
|
f'{e}. NOTE: command may still running in guest, '
|
|
|
|
f'PID={ga.last_pid}'
|
|
|
|
)
|
|
|
|
if output.stderr:
|
|
|
|
print(output.stderr.strip(), file=sys.stderr)
|
|
|
|
if output.stdout:
|
|
|
|
print(output.stdout.strip(), file=sys.stdout)
|
|
|
|
sys.exit(output.exitcode)
|
|
|
|
|
|
|
|
|
2023-11-09 01:17:50 +03:00
|
|
|
class _NotPresent:
|
|
|
|
"""
|
|
|
|
Type for representing non-existent dictionary keys.
|
|
|
|
|
|
|
|
See :class:`_FillableDict`.
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
class _FillableDict(UserDict):
|
|
|
|
"""Use :method:`fill` to add key if not present."""
|
|
|
|
|
|
|
|
def __init__(self, data: dict):
|
|
|
|
self.data = data
|
|
|
|
|
|
|
|
def fill(self, key: str, value: Any) -> None: # noqa: ANN401
|
|
|
|
if self.data.get(key, _NotPresent) is _NotPresent:
|
|
|
|
self.data[key] = value
|
|
|
|
|
|
|
|
|
|
|
|
def _merge_dicts(a: dict, b: dict, path: list[str] | None = None) -> dict:
|
|
|
|
"""Merge `b` into `a`. Return modified `a`."""
|
|
|
|
if path is None:
|
|
|
|
path = []
|
|
|
|
for key in b:
|
|
|
|
if key in a:
|
|
|
|
if isinstance(a[key], dict) and isinstance(b[key], dict):
|
|
|
|
_merge_dicts(a[key], b[key], [path + str(key)])
|
|
|
|
elif a[key] == b[key]:
|
|
|
|
pass # same leaf value
|
|
|
|
else:
|
|
|
|
a[key] = b[key] # replace existing key's values
|
|
|
|
else:
|
|
|
|
a[key] = b[key]
|
|
|
|
return a
|
|
|
|
|
|
|
|
|
|
|
|
def _create_instance(session: Session, file: io.TextIOWrapper) -> None:
|
|
|
|
try:
|
|
|
|
data = _FillableDict(yaml.load(file.read(), Loader=yaml.SafeLoader))
|
|
|
|
log.debug('Read from file: %s', data)
|
|
|
|
except yaml.YAMLError as e:
|
|
|
|
sys.exit(f'error: cannot parse YAML: {e}')
|
|
|
|
|
|
|
|
capabilities = session.get_capabilities()
|
|
|
|
node_info = session.get_node_info()
|
|
|
|
|
|
|
|
data.fill('name', uuid4().hex)
|
|
|
|
data.fill('title', None)
|
|
|
|
data.fill('description', None)
|
|
|
|
data.fill('arch', capabilities.arch)
|
|
|
|
data.fill('machine', capabilities.machine)
|
|
|
|
data.fill('emulator', capabilities.emulator)
|
|
|
|
data.fill('max_vcpus', node_info.cpus)
|
|
|
|
data.fill('max_memory', node_info.memory)
|
|
|
|
data.fill('cpu', {})
|
|
|
|
cpu = {
|
|
|
|
'emulation_mode': 'host-passthrough',
|
|
|
|
'model': None,
|
|
|
|
'vendor': None,
|
|
|
|
'topology': None,
|
|
|
|
'features': None,
|
|
|
|
}
|
|
|
|
data['cpu'] = _merge_dicts(data['cpu'], cpu)
|
|
|
|
data.fill(
|
|
|
|
'network_interfaces',
|
|
|
|
[{'source': 'default', 'mac': ids.random_mac()}],
|
|
|
|
)
|
|
|
|
data.fill('boot', {'order': ['cdrom', 'hd']})
|
|
|
|
|
|
|
|
try:
|
|
|
|
log.debug('Input data: %s', data)
|
|
|
|
session.create_instance(**data)
|
|
|
|
except ValidationError as e:
|
|
|
|
for error in e.errors():
|
|
|
|
fields = '.'.join([str(lc) for lc in error['loc']])
|
|
|
|
print(
|
|
|
|
f"validation error: {fields}: {error['msg']}",
|
|
|
|
file=sys.stderr,
|
|
|
|
)
|
|
|
|
sys.exit()
|
|
|
|
|
|
|
|
|
2023-11-06 12:52:19 +03:00
|
|
|
def main(session: Session, args: argparse.Namespace) -> None:
|
|
|
|
"""Perform actions."""
|
|
|
|
match args.command:
|
|
|
|
case 'create':
|
2023-11-09 01:17:50 +03:00
|
|
|
_create_instance(session, args.file)
|
2023-11-06 12:52:19 +03:00
|
|
|
case 'exec':
|
|
|
|
_exec_guest_agent_command(session, args)
|
|
|
|
case 'ls':
|
|
|
|
_list_instances(session)
|
|
|
|
case 'start':
|
|
|
|
instance = session.get_instance(args.instance)
|
|
|
|
instance.start()
|
|
|
|
case 'shutdown':
|
|
|
|
instance = session.get_instance(args.instance)
|
|
|
|
instance.shutdown(args.method)
|
|
|
|
case 'reboot':
|
|
|
|
instance = session.get_instance(args.instance)
|
|
|
|
instance.reboot()
|
|
|
|
case 'reset':
|
|
|
|
instance = session.get_instance(args.instance)
|
|
|
|
instance.reset()
|
|
|
|
case 'status':
|
|
|
|
instance = session.get_instance(args.instance)
|
|
|
|
print(instance.status)
|
|
|
|
case 'setvcpus':
|
|
|
|
instance = session.get_instance(args.instance)
|
|
|
|
instance.set_vcpus(args.nvcpus, live=True)
|
|
|
|
|
|
|
|
|
|
|
|
def cli() -> None: # noqa: PLR0915
|
|
|
|
"""Parse command line arguments."""
|
|
|
|
root = argparse.ArgumentParser(
|
|
|
|
prog='compute',
|
|
|
|
description='manage compute instances and storage volumes.',
|
|
|
|
formatter_class=argparse.RawTextHelpFormatter,
|
|
|
|
)
|
|
|
|
root.add_argument(
|
|
|
|
'-v',
|
|
|
|
'--verbose',
|
|
|
|
action='store_true',
|
|
|
|
default=False,
|
|
|
|
help='enable verbose mode',
|
|
|
|
)
|
|
|
|
root.add_argument(
|
|
|
|
'-c',
|
|
|
|
'--connect',
|
|
|
|
metavar='URI',
|
|
|
|
default='qemu:///system',
|
|
|
|
help='libvirt connection URI',
|
|
|
|
)
|
|
|
|
root.add_argument(
|
|
|
|
'-l',
|
|
|
|
'--log-level',
|
|
|
|
metavar='LEVEL',
|
|
|
|
choices=log_levels,
|
|
|
|
help='log level [envvar: CMP_LOG]',
|
|
|
|
)
|
|
|
|
root.add_argument(
|
|
|
|
'-V',
|
|
|
|
'--version',
|
|
|
|
action='version',
|
|
|
|
version=__version__,
|
|
|
|
)
|
|
|
|
subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
|
|
|
|
|
|
|
|
# create command
|
2023-11-09 01:17:50 +03:00
|
|
|
create = subparsers.add_parser(
|
|
|
|
'create', help='create new instance from YAML config file'
|
|
|
|
)
|
2023-11-06 12:52:19 +03:00
|
|
|
create.add_argument(
|
2023-11-09 01:17:50 +03:00
|
|
|
'file',
|
|
|
|
type=argparse.FileType('r', encoding='UTF-8'),
|
2023-11-06 12:52:19 +03:00
|
|
|
)
|
|
|
|
|
|
|
|
# exec subcommand
|
|
|
|
execute = subparsers.add_parser(
|
|
|
|
'exec',
|
|
|
|
help='execute command in guest via guest agent',
|
|
|
|
description=(
|
|
|
|
'NOTE: any argument after instance name will be passed into '
|
|
|
|
'guest as shell command.'
|
|
|
|
),
|
|
|
|
)
|
|
|
|
execute.add_argument('instance')
|
|
|
|
execute.add_argument('arguments', nargs=argparse.REMAINDER)
|
|
|
|
execute.add_argument(
|
|
|
|
'-t',
|
|
|
|
'--timeout',
|
|
|
|
type=int,
|
|
|
|
default=60,
|
|
|
|
help=(
|
|
|
|
'waiting time in seconds for a command to be executed '
|
|
|
|
'in guest, 60 sec by default'
|
|
|
|
),
|
|
|
|
)
|
|
|
|
execute.add_argument(
|
|
|
|
'-s',
|
|
|
|
'--shell',
|
|
|
|
default='/bin/sh',
|
|
|
|
help='path to executable in guest, /bin/sh by default',
|
|
|
|
)
|
|
|
|
execute.add_argument(
|
|
|
|
'-e',
|
|
|
|
'--env',
|
|
|
|
type=str,
|
|
|
|
nargs='?',
|
|
|
|
action='append',
|
|
|
|
help='environment variables to pass to executable in guest',
|
|
|
|
)
|
|
|
|
execute.add_argument(
|
|
|
|
'-n',
|
|
|
|
'--no-cmd-string',
|
|
|
|
action='store_true',
|
|
|
|
default=False,
|
|
|
|
help=(
|
|
|
|
"do not append '-c' option to arguments list, suitable "
|
|
|
|
'for non-shell executables and other specific cases.'
|
|
|
|
),
|
|
|
|
)
|
|
|
|
|
|
|
|
# ls subcommand
|
|
|
|
listall = subparsers.add_parser('ls', help='list instances')
|
|
|
|
listall.add_argument(
|
|
|
|
'-a',
|
|
|
|
'--all',
|
|
|
|
action='store_true',
|
|
|
|
default=False,
|
|
|
|
help='list all instances including inactive',
|
|
|
|
)
|
|
|
|
|
|
|
|
# start subcommand
|
|
|
|
start = subparsers.add_parser('start', help='start instance')
|
|
|
|
start.add_argument('instance')
|
|
|
|
|
|
|
|
# shutdown subcommand
|
|
|
|
shutdown = subparsers.add_parser('shutdown', help='shutdown instance')
|
|
|
|
shutdown.add_argument('instance')
|
|
|
|
shutdown.add_argument(
|
|
|
|
'-m',
|
|
|
|
'--method',
|
|
|
|
choices=['soft', 'normal', 'hard', 'unsafe'],
|
|
|
|
default='normal',
|
|
|
|
help='use shutdown method',
|
|
|
|
)
|
|
|
|
|
|
|
|
# reboot subcommand
|
|
|
|
reboot = subparsers.add_parser('reboot', help='reboot instance')
|
|
|
|
reboot.add_argument('instance')
|
|
|
|
|
|
|
|
# reset subcommand
|
|
|
|
reset = subparsers.add_parser('reset', help='reset instance')
|
|
|
|
reset.add_argument('instance')
|
|
|
|
|
|
|
|
# status subcommand
|
|
|
|
status = subparsers.add_parser('status', help='display instance status')
|
|
|
|
status.add_argument('instance')
|
|
|
|
|
|
|
|
# setvcpus subcommand
|
|
|
|
setvcpus = subparsers.add_parser('setvcpus', help='set vCPU number')
|
|
|
|
setvcpus.add_argument('instance')
|
|
|
|
setvcpus.add_argument('nvcpus', type=int)
|
|
|
|
|
|
|
|
# Run parser
|
|
|
|
args = root.parse_args()
|
|
|
|
if args.command is None:
|
|
|
|
root.print_help()
|
|
|
|
sys.exit()
|
|
|
|
|
|
|
|
# Set logging level
|
|
|
|
log_level = args.log_level or env_log_level
|
|
|
|
if log_level in log_levels:
|
|
|
|
logging.basicConfig(level=log_levels[log_level])
|
|
|
|
|
2023-11-09 01:17:50 +03:00
|
|
|
log.debug('CLI started with args: %s', args)
|
2023-11-06 12:52:19 +03:00
|
|
|
# Perform actions
|
|
|
|
try:
|
|
|
|
with Session(args.connect) as session:
|
|
|
|
main(session, args)
|
|
|
|
except ComputeServiceError as e:
|
|
|
|
sys.exit(f'error: {e}')
|
2023-11-09 01:17:50 +03:00
|
|
|
except KeyboardInterrupt:
|
2023-11-06 12:52:19 +03:00
|
|
|
sys.exit()
|
2023-11-09 01:17:50 +03:00
|
|
|
except SystemExit as e:
|
|
|
|
sys.exit(e)
|
2023-11-06 12:52:19 +03:00
|
|
|
except Exception as e: # noqa: BLE001
|
|
|
|
sys.exit(f'unexpected error {type(e)}: {e}')
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == '__main__':
|
|
|
|
cli()
|