python-compute/compute/cli/control.py

502 lines
14 KiB
Python
Raw Normal View History

2023-11-23 02:34:02 +03:00
# This file is part of Compute
#
# Compute is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# Ansible is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with Ansible. If not, see <http://www.gnu.org/licenses/>.
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__
2023-12-01 01:39:26 +03:00
from compute.exceptions import ComputeError, GuestAgentTimeoutError
2023-11-06 12:52:19 +03:00
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__)
2023-11-09 20:32:57 +03:00
log_levels = [lv.lower() for lv in logging.getLevelNamesMapping()]
2023-11-06 12:52:19 +03:00
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()
2023-11-09 02:21:42 +03:00
if len(arguments) > 1 and not args.no_join_args:
2023-11-06 12:52:19 +03:00
arguments = [shlex.join(arguments)]
2023-11-09 02:21:42 +03:00
if not args.no_join_args:
2023-11-06 12:52:19 +03:00
arguments.insert(0, '-c')
stdin = None
if not sys.stdin.isatty():
stdin = sys.stdin.read()
try:
output = ga.guest_exec(
2023-11-09 02:21:42 +03:00
path=args.executable,
2023-11-06 12:52:19 +03:00
args=arguments,
env=args.env,
stdin=stdin,
capture_output=True,
decode_output=True,
poll=True,
)
2023-12-01 01:39:26 +03:00
except GuestAgentTimeoutError as e:
2023-11-06 12:52:19 +03:00
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-23 02:34:02 +03:00
def _shutdown_instance(session: Session, args: argparse.Namespace) -> None:
instance = session.get_instance(args.instance)
if args.soft:
method = 'SOFT'
elif args.hard:
method = 'HARD'
elif args.unsafe:
method = 'UNSAFE'
else:
method = 'NORMAL'
instance.shutdown(method)
2023-11-06 12:52:19 +03:00
def main(session: Session, args: argparse.Namespace) -> None:
"""Perform actions."""
match args.command:
2023-11-23 02:34:02 +03:00
case 'init':
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':
2023-11-23 02:34:02 +03:00
_shutdown_instance(session, args)
2023-11-06 12:52:19 +03:00
case 'reboot':
instance = session.get_instance(args.instance)
instance.reboot()
case 'reset':
instance = session.get_instance(args.instance)
instance.reset()
2023-11-23 02:34:02 +03:00
case 'powrst':
instance = session.get_instance(args.instance)
instance.power_reset()
2023-11-09 22:35:19 +03:00
case 'pause':
instance = session.get_instance(args.instance)
instance.pause()
case 'resume':
instance = session.get_instance(args.instance)
instance.resume()
2023-11-06 12:52:19 +03:00
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)
2023-11-09 22:35:19 +03:00
case 'setmem':
instance = session.get_instance(args.instance)
instance.set_memory(args.memory, live=True)
2023-11-23 02:34:02 +03:00
case 'setpass':
2023-11-11 02:28:46 +03:00
instance = session.get_instance(args.instance)
instance.set_user_password(
args.username,
args.password,
encrypted=args.encrypted,
)
2023-11-06 12:52:19 +03:00
def cli() -> None: # noqa: PLR0915
2023-11-11 02:28:46 +03:00
"""Return command line arguments parser."""
2023-11-06 12:52:19 +03:00
root = argparse.ArgumentParser(
prog='compute',
2023-11-11 02:28:46 +03:00
description='manage compute instances',
2023-11-06 12:52:19 +03:00
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',
help='libvirt connection URI',
)
root.add_argument(
'-l',
'--log-level',
2023-11-09 20:32:57 +03:00
type=str.lower,
2023-11-06 12:52:19 +03:00
metavar='LEVEL',
choices=log_levels,
2023-11-23 02:34:02 +03:00
help='log level',
2023-11-06 12:52:19 +03:00
)
root.add_argument(
'-V',
'--version',
action='version',
version=__version__,
)
subparsers = root.add_subparsers(dest='command', metavar='COMMAND')
2023-11-23 02:34:02 +03:00
# init command
init = subparsers.add_parser(
'init', help='initialise instance using YAML config file'
2023-11-09 01:17:50 +03:00
)
2023-11-23 02:34:02 +03:00
init.add_argument(
2023-11-09 01:17:50 +03:00
'file',
type=argparse.FileType('r', encoding='UTF-8'),
2023-11-23 02:34:02 +03:00
nargs='?',
default='instance.yaml',
help='instance config [default: instance.yaml]',
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 '
2023-11-23 02:34:02 +03:00
'in guest [default: 60]'
2023-11-06 12:52:19 +03:00
),
)
execute.add_argument(
2023-11-09 02:21:42 +03:00
'-x',
'--executable',
2023-11-06 12:52:19 +03:00
default='/bin/sh',
2023-11-23 02:34:02 +03:00
help='path to executable in guest [default: /bin/sh]',
2023-11-06 12:52:19 +03:00
)
execute.add_argument(
'-e',
'--env',
type=str,
nargs='?',
action='append',
help='environment variables to pass to executable in guest',
)
execute.add_argument(
'-n',
2023-11-09 02:21:42 +03:00
'--no-join-args',
2023-11-06 12:52:19 +03:00
action='store_true',
default=False,
help=(
2023-11-09 02:21:42 +03:00
"do not join arguments list and add '-c' option, suitable "
2023-11-06 12:52:19 +03:00
'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')
2023-11-23 02:34:02 +03:00
shutdown_opts = shutdown.add_mutually_exclusive_group()
shutdown_opts.add_argument(
'-s',
'--soft',
action='store_true',
help='normal guest OS shutdown, guest agent is used',
)
shutdown_opts.add_argument(
'-n',
'--normal',
action='store_true',
help='shutdown with hypervisor selected method [default]',
)
shutdown_opts.add_argument(
'-H',
'--hard',
action='store_true',
help=(
"gracefully destroy instance, it's like long "
'pressing the power button'
),
)
shutdown_opts.add_argument(
'-u',
'--unsafe',
action='store_true',
help=(
'destroy instance, this is similar to a power outage '
'and may result in data loss or corruption'
),
2023-11-06 12:52:19 +03:00
)
# 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')
2023-11-23 02:34:02 +03:00
# powrst subcommand
powrst = subparsers.add_parser('powrst', help='power reset instance')
powrst.add_argument('instance')
2023-11-09 22:35:19 +03:00
# pause subcommand
pause = subparsers.add_parser('pause', help='pause instance')
pause.add_argument('instance')
# resume subcommand
resume = subparsers.add_parser('resume', help='resume paused instance')
resume.add_argument('instance')
2023-11-06 12:52:19 +03:00
# 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)
2023-11-09 22:35:19 +03:00
# setmem subcommand
setmem = subparsers.add_parser('setmem', help='set memory size')
setmem.add_argument('instance')
setmem.add_argument('memory', type=int, help='memory in MiB')
2023-11-23 02:34:02 +03:00
# setpass subcommand
setpass = subparsers.add_parser(
'setpass',
2023-11-11 02:28:46 +03:00
help='set user password in guest',
)
2023-11-23 02:34:02 +03:00
setpass.add_argument('instance')
setpass.add_argument('username')
setpass.add_argument('password')
setpass.add_argument(
2023-11-11 02:28:46 +03:00
'-e',
'--encrypted',
action='store_true',
default=False,
help='set it if password is already encrypted',
)
2023-11-06 12:52:19 +03:00
args = root.parse_args()
if args.command is None:
root.print_help()
sys.exit()
2023-11-09 20:32:57 +03:00
log_level = args.log_level or os.getenv('CMP_LOG')
if isinstance(log_level, str) and log_level.lower() in log_levels:
logging.basicConfig(
level=logging.getLevelNamesMapping()[log_level.upper()]
)
2023-11-06 12:52:19 +03:00
2023-11-09 01:17:50 +03:00
log.debug('CLI started with args: %s', args)
2023-11-23 02:34:02 +03:00
connect_uri = (
args.connect
or os.getenv('CMP_LIBVIRT_URI')
or os.getenv('LIBVIRT_DEFAULT_URI')
or 'qemu:///system'
)
2023-11-06 12:52:19 +03:00
try:
2023-11-23 02:34:02 +03:00
with Session(connect_uri) as session:
2023-11-06 12:52:19 +03:00
main(session, args)
2023-11-23 02:34:02 +03:00
except ComputeError as e:
2023-11-06 12:52:19 +03:00
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()