From 7d604fb3e392d14877c75ce8b19f1096e4fe6a7c Mon Sep 17 00:00:00 2001 From: Andreas Maier Date: Tue, 4 May 2021 12:55:37 +0200 Subject: [PATCH] Added listener command group Details: * TBD Signed-off-by: Andreas Maier --- pywbemtools/pywbemcli/__init__.py | 1 + pywbemtools/pywbemcli/_cmd_listener.py | 473 +++++++++++++++++++++++++ 2 files changed, 474 insertions(+) create mode 100644 pywbemtools/pywbemcli/_cmd_listener.py diff --git a/pywbemtools/pywbemcli/__init__.py b/pywbemtools/pywbemcli/__init__.py index 4dea3d7bb..78fda3a78 100644 --- a/pywbemtools/pywbemcli/__init__.py +++ b/pywbemtools/pywbemcli/__init__.py @@ -32,6 +32,7 @@ from ._cmd_connection import * # noqa: F403,F401 from ._cmd_profile import * # noqa: F403,F401 from ._cmd_statistics import * # noqa: F403,F401 +from ._cmd_listener import * # noqa: F403,F401 from ._context_obj import * # noqa: F403,F401 from ._connection_repository import * # noqa: F403,F401 from .pywbemcli import * # noqa: F403,F401 diff --git a/pywbemtools/pywbemcli/_cmd_listener.py b/pywbemtools/pywbemcli/_cmd_listener.py new file mode 100644 index 000000000..63c552b3e --- /dev/null +++ b/pywbemtools/pywbemcli/_cmd_listener.py @@ -0,0 +1,473 @@ +# (C) Copyright 2021 IBM Corp. +# (C) Copyright 2021 Inova Development Inc. +# All Rights Reserved +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +""" +Click Command definition for the listener command group which includes +cmds to manage indication listeners that are separate invocations of +pywbemcli representing a listener. + +NOTE: Commands are ordered in help display by their order in this file. +""" + +from __future__ import absolute_import, print_function + +import subprocess +import signal +import argparse +from time import sleep +import click +import psutil + +from pywbem import WBEMListener + +from .pywbemcli import cli +from ._common import CMD_OPTS_TXT, GENERAL_OPTS_TXT, SUBCMD_HELP_TXT, \ + format_table +from ._common_options import add_options, help_option +from ._click_extensions import PywbemcliGroup, PywbemcliCommand + +DEFAULT_LISTENER_PORT = 5988 +DEFAULT_LISTENER_PROTOCOL = 'http' + +LISTEN_OPTIONS = [ + click.option('--port', type=int, metavar='PORT', + required=False, default=DEFAULT_LISTENER_PORT, + help=u'The port number listened on. ' + 'Default: {}'.format(DEFAULT_LISTENER_PORT)), + click.option('--protocol', type=click.Choice(['http', 'https']), + metavar='PROTOCOL', + required=False, default=DEFAULT_LISTENER_PROTOCOL, + help=u'The protocol used by the listener (http, https). ' + 'Default: {}'.format(DEFAULT_LISTENER_PROTOCOL)), + click.option('--certfile', type=str, metavar='FILE', + required=False, default=None, + help=u'Path name of a PEM file containing the certificate ' + 'that will be presented as a server certificate during ' + 'SSL/TLS handshake. Required when using https. ' + 'The file may in addition contain the private key of the ' + 'certificate.'), + click.option('--keyfile', type=str, metavar='FILE', + required=False, default=None, + help=u'Path name of a PEM file containing the private key ' + 'of the server certificate. ' + 'Required when using https and when the certificate file ' + 'does not contain the private key.'), +] + + +class ListenerProperties(object): + """ + The properties of a running named listener. + """ + + def __init__(self, pid, name, port, protocol, general_options): + self._pid = pid + self._name = name + self._port = port + self._protocol = protocol + self._general_options = general_options + + def as_string_tuple(self): + """Return the object as a tuple of strings""" + return (str(self._pid), self._name, str(self._port), self._protocol, + ' '.join(self._general_options)) + + @property + def pid(self): + """int: Process ID of the listener""" + return self._pid + + @property + def name(self): + """string: Name of the listener""" + return self._name + + @property + def port(self): + """int: Port number of the listener""" + return self._port + + @property + def protocol(self): + """string: Protocol of the listener""" + return self._name + + @property + def general_options(self): + """list of string: General pywbemcli options of the listener""" + return self._general_options + + +@cli.group('listener', cls=PywbemcliGroup, options_metavar=GENERAL_OPTS_TXT, + subcommand_metavar=SUBCMD_HELP_TXT) +@add_options(help_option) +def listener_group(): + """ + Command group for WBEM indication listeners. + + This command group defines commands to manage WBEM indication listeners. + Each listener is a running process that executes the + `pywbemcli listener run` command acting as a listener. + + These processes can be started manually by the user by invoking the + `pywbemcli listener run` command in the background, or by using the + `pywbemcli listener start` command which does exactly that. + + There is no persisted data about the currently running listeners, instead + the currently running processes executing the `pywbemcli listener run` + command are the currently running listeners. + + If such a listener process terminates, the corresponding listener no longer + exists, so there is no notion of a stopped listener nor does a listener + have an operational status. The `pywbemcli listener stop` command terminates + a listener gracefully. + + In addition to the command-specific options shown in this help text, the + general options (see 'pywbemcli --help') can also be specified before the + 'connection' keyword. + """ + pass # pylint: disable=unnecessary-pass + + +@listener_group.command('run', cls=PywbemcliCommand, + options_metavar=CMD_OPTS_TXT) +@click.argument('name', type=str, metavar='NAME', required=True) +@add_options(LISTEN_OPTIONS) +@add_options(help_option) +@click.pass_obj +def listener_run(context, **options): + """ + Run as a named WBEM indication listener. + + Run this command as a named WBEM indication listener until it gets + terminated, e.g. by a keyboard interrupt, break signal (e.g. kill), or the + `pywbemcli listener stop` command. + + A listener with that name must not be running, otherwise the command fails. + + Examples: + + pywbemcli listener run lis1 + """ + context.execute_cmd(lambda: cmd_listener_run(context, options)) + + +@listener_group.command('start', cls=PywbemcliCommand, + options_metavar=CMD_OPTS_TXT) +@click.argument('name', type=str, metavar='NAME', required=True) +@add_options(LISTEN_OPTIONS) +@add_options(help_option) +@click.pass_obj +def listener_start(context, **options): + """ + Start a named WBEM indication listener in the background. + + A listener with that name must not be running, otherwise the command fails. + + Examples: + + pywbemcli listener start lis1 + """ + context.execute_cmd(lambda: cmd_listener_start(context, options)) + + +@listener_group.command('stop', cls=PywbemcliCommand, + options_metavar=CMD_OPTS_TXT) +@click.argument('name', type=str, metavar='NAME', required=True) +@add_options(help_option) +@click.pass_obj +def listener_stop(context, name): + """ + Stop a named WBEM indication listener. + + The listener is stopped gracefully. + + A listener with that name must be running, otherwise the command fails. + + Examples: + + pywbemcli listener stop lis1 + """ + context.execute_cmd(lambda: cmd_listener_stop(context, name)) + + +@listener_group.command('show', cls=PywbemcliCommand, + options_metavar=CMD_OPTS_TXT) +@click.argument('name', type=str, metavar='NAME', required=True) +@add_options(help_option) +@click.pass_obj +def listener_show(context, name): + """ + Show a named WBEM indication listener. + + A listener with that name must be running, otherwise the command fails. + + Examples: + + pywbemcli listener stop lis1 + """ + context.execute_cmd(lambda: cmd_listener_show(context, name)) + + +@listener_group.command('list', cls=PywbemcliCommand, + options_metavar=CMD_OPTS_TXT) +@add_options(help_option) +@click.pass_obj +def listener_list(context): + """ + List the currently running named WBEM indication listeners. + + This is done by listing the currently running `pywbemcli listener run` + commands. + """ + context.execute_cmd(lambda: cmd_listener_list(context)) + + +################################################################ +# +# Common methods for The action functions for the listener click group +# +############################################################### + +def get_listeners(name=None): + """ + List the running listener processes, or the running listener process with + the specified name. + + Returns: + list of ListenerProperties + """ + ret = [] + for p in psutil.process_iter(): + try: + cmdline = p.cmdline() + except (psutil.AccessDenied, psutil.ZombieProcess): + # Ignore processes we cannot access + continue + seen_pywbemcli = False + for i, item in enumerate(cmdline): + if item.endswith('pywbemcli'): + seen_pywbemcli = True + if seen_pywbemcli and item == 'listener': + listener_index = i + break + else: + # Ignore processes that are not 'pywbemcli [opts] listener' + continue + listener_args = cmdline[listener_index + 1:] # After 'listener' + args = parse_listener_args(listener_args) + if args: + if name is None or args.name == name: + listener = ListenerProperties( + pid=p.pid, name=args.name, port=args.port, + protocol=args.protocol, general_options=[]) + ret.append(listener) + return ret + + +class SilentArgumentParser(argparse.ArgumentParser): + """ + argparse.ArgumentParser class that silences any errors and exit and + just raises them as SystemExit. + """ + + def error(self, message=None): + """Called for usage errors detected by the parser""" + raise SystemExit(2) + + def exit(self, status=0, message=None): + """Not sure when this is called""" + raise SystemExit(status) + + +def parse_listener_args(listener_args): + """ + Parse the command line arguments of a process. If it is a listener process + return its parsed arguments; otherwise return None. + """ + + parser = SilentArgumentParser() + parser.add_argument('run', type=str, default=None) + parser.add_argument('name', type=str, default=None) + parser.add_argument('--port', type=int, default=DEFAULT_LISTENER_PORT) + parser.add_argument('--protocol', type=str, + default=DEFAULT_LISTENER_PROTOCOL) + parser.add_argument('--certfile', type=str, default=None) + parser.add_argument('--keyfile', type=str, default=None) + + try: + parsed_args = parser.parse_args(listener_args) + except SystemExit: + # Invalid arguments + return None + + if parsed_args.run != 'run': + return None + + return parsed_args + + +def signal_handler(sig, frame): + """Signal handler for the listener run command""" + raise SystemExit(1) + + +def show_listeners(listeners): + """ + Show one or a list of listeners + """ + headers = ['PID', 'name', 'port', 'protocol', 'general options'] + if not isinstance(listeners, (list, tuple)): + listeners = [listeners] + rows = [] + for listener in listeners: + rows.append(listener.as_string_tuple()) + table = format_table( + rows, headers, table_format='simple', + sort_columns=None, hide_empty_cols=None, float_fmt=None) + click.echo(table) + + +################################################################ +# +# Common methods for The action functions for the listener click group +# +############################################################### + +def cmd_listener_run(context, options): + """ + Run as a listener. + """ + name = options['name'] + port = options['port'] + protocol = options['protocol'] + + host = 'localhost' + if protocol == 'http': + http_port = port + https_port = None + certfile = None + keyfile = None + else: + assert protocol == 'https' + https_port = port + http_port = None + certfile = options['certfile'] + keyfile = options['keyfile'] + url = '{}://{}:{}'.format(protocol, host, port) + + context.spinner_stop() + + try: + listener = WBEMListener( + host=host, http_port=http_port, https_port=https_port, + certfile=certfile, keyfile=keyfile) + except ValueError as exc: + raise click.ClickException( + "Cannot create listener {}: {}".format(name, exc)) + try: + listener.start() + except (IOError, OSError) as exc: + raise click.ClickException( + "Cannot start listener {}: {}".format(name, exc)) + + signal.signal(signal.SIGTERM, signal_handler) + + click.echo("Running listener at {}".format(url)) + + try: + while True: + sleep(60) + except KeyboardInterrupt: + listener.stop() + click.echo("Terminated listener {} due to keyboard interrupt". + format(name)) + except SystemExit: + listener.stop() + click.echo("Terminated listener {} due to termination signal". + format(name)) + + +def cmd_listener_start(context, options): + """ + Start a named listener. + """ + name = options['name'] + port = options['port'] + protocol = options['protocol'] + certfile = options['certfile'] + keyfile = options['keyfile'] + + listeners = get_listeners(name) + if listeners: + raise click.ClickException( + "Listener {} already runs".format(name)) + + run_args = [ + 'pywbemcli', 'listener', 'run', name, + '--port', str(port), + '--protocol', protocol, + ] + if certfile: + run_args.extend(['--certfile', certfile]) + if keyfile: + run_args.extend(['--keyfile', keyfile]) + + context.spinner_stop() + # pylint: disable=consider-using-with + p = subprocess.Popen(run_args, start_new_session=True) + click.echo("Started listener {} as detached process {}".format(name, p.pid)) + + +def cmd_listener_stop(context, name): + """ + Show the properties of a named listener. + """ + listeners = get_listeners(name) + if not listeners: + raise click.ClickException( + "No running listener found with name {}".format(name)) + listener = listeners[0] + + context.spinner_stop() + p = psutil.Process(listener.pid) + p.terminate() + click.echo("Stopped listener {}".format(name)) + + +def cmd_listener_show(context, name): + """ + Show the properties of a named listener. + """ + listeners = get_listeners(name) + if not listeners: + raise click.ClickException( + "No running listener found with name {}".format(name)) + + context.spinner_stop() + show_listeners(listeners) + + +def cmd_listener_list(context): + """ + List the properties of all running named listeners. + """ + listeners = get_listeners() + context.spinner_stop() + if not listeners: + click.echo("No running listeners") + else: + show_listeners(listeners)