zhmcclient/zhmccli

View on GitHub
zhmccli/zhmccli.py

Summary

Maintainability
D
2 days
Test Coverage
# Copyright 2016,2019 IBM Corp. 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.

"""
Main script.
"""

from __future__ import absolute_import, print_function

import os
import sys
import logging
from logging.handlers import SysLogHandler
from logging import StreamHandler, NullHandler
import platform
import urllib3
import click
import click_repl
from prompt_toolkit.history import FileHistory

import zhmcclient
import zhmcclient_mock
from ._helper import CmdContext, GENERAL_OPTIONS_METAVAR, REPL_HISTORY_FILE, \
    REPL_PROMPT, TABLE_FORMATS, LOG_LEVELS, LOG_DESTINATIONS, \
    SYSLOG_FACILITIES, click_exception, get_click_terminal_width

urllib3.disable_warnings()


# Default values for some options
DEFAULT_OUTPUT_FORMAT = 'table'
DEFAULT_ERROR_FORMAT = 'msg'
DEFAULT_TIMESTATS = False
DEFAULT_LOG = 'all=warning'
DEFAULT_LOG_DESTINATION = 'stderr'
DEFAULT_SYSLOG_FACILITY = 'user'
DEFAULT_NO_VERIFY = False

CONSOLE_LOGGER_NAME = 'zhmccli.console'

ERROR_FORMATS = ['msg', 'def']

# List of values to try for the 'address' parameter when creating
# a SysLogHandler object.
# Key: Operating system type, as returned by platform.system(). For CygWin,
# the returned value is 'CYGWIN_NT-6.1', which is special-cased to 'CYGWIN_NT'.
# Value: List of values for the 'address' parameter; to be tried in the
# specified order.
SYSLOG_ADDRESSES = {
    'Linux': ['/dev/log', ('localhost', 514)],
    'Darwin': ['/var/run/syslog', ('localhost', 514)],  # OS-X
    'Windows': [('localhost', 514)],
    'CYGWIN_NT': ['/dev/log', ('localhost', 514)],  # Requires syslog-ng pkg
}

# The getattr() is used to work around a Pylint false positive no-member issue
ZHMCCLIENT_VERSION = "zhmcclient, version {v}".format(
    v=getattr(zhmcclient, '__version__', 'unknown'))

# Logger names by log component
LOGGER_NAMES = {
    'all': '',  # root logger
    'api': zhmcclient.API_LOGGER_NAME,
    'hmc': zhmcclient.HMC_LOGGER_NAME,
    'console': CONSOLE_LOGGER_NAME,
}
LOG_COMPONENTS = LOGGER_NAMES.keys()

# Context variables passed to Click
CLICK_CONTEXT_SETTINGS = dict(

    # Set the terminal width - used e.g. for Click help messages
    terminal_width=get_click_terminal_width(),
)


@click.group(invoke_without_command=True,
             context_settings=CLICK_CONTEXT_SETTINGS,
             options_metavar=GENERAL_OPTIONS_METAVAR)
@click.option('-h', '--host', type=str, envvar='ZHMC_HOST',
              help="Hostname or IP address of the HMC "
                   "(Default: ZHMC_HOST environment variable).")
@click.option('-u', '--userid', type=str, envvar='ZHMC_USERID',
              help="Username for the HMC "
                   "(Default: ZHMC_USERID environment variable).")
@click.option('-p', '--password', type=str, envvar='ZHMC_PASSWORD',
              help="Password for the HMC "
                   "(Default: ZHMC_PASSWORD environment variable).")
@click.option('-n', '--no-verify', is_flag=True, default=None,
              envvar='ZHMC_NO_VERIFY',
              help="Do not verify the HMC certificate. "
                   "(Default: ZHMC_NO_VERIFY environment variable, or verify "
                   "the HMC certificate).")
@click.option('-c', '--ca-certs', type=str, envvar='ZHMC_CA_CERTS',
              help="Path name of certificate file or directory with CA "
                   "certificates to be used for verifying the HMC certificate. "
                   "(Default: Path name in ZHMC_CA_CERTS environment variable, "
                   "or path name in REQUESTS_CA_BUNDLE environment variable, "
                   "or path name in CURL_CA_BUNDLE environment variable, "
                   "or the 'certifi' Python package which provides the "
                   "Mozilla CA Certificate List).")
@click.option('-o', '--output-format',
              type=click.Choice(TABLE_FORMATS + ['json']),
              help='Output format (Default: {def_of}).'.
              format(def_of=DEFAULT_OUTPUT_FORMAT))
@click.option('-x', '--transpose', type=str, is_flag=True,
              help='Transpose the output table for metrics.')
@click.option('-e', '--error-format', type=click.Choice(ERROR_FORMATS),
              help='Error message format (Default: {def_ef}).'.
              format(def_ef=DEFAULT_ERROR_FORMAT))
@click.option('-t', '--timestats', type=str, is_flag=True,
              help='Show time statistics of HMC operations.')
@click.option('--log', type=str, metavar='COMP=LEVEL,...',
              help="Set a component to a log level (COMP: [{comps}], "
              "LEVEL: [{levels}], Default: {def_log}).".
              format(comps='|'.join(LOG_COMPONENTS),
                     levels='|'.join(LOG_LEVELS),
                     def_log=DEFAULT_LOG))
@click.option('--log-dest', type=str,
              metavar='[{}]'.format('|'.join(LOG_DESTINATIONS)),
              help="Log destination for this command (Default: {def_dest}).".
              format(def_dest=DEFAULT_LOG_DESTINATION))
@click.option('--syslog-facility', type=click.Choice(SYSLOG_FACILITIES),
              help="Syslog facility when logging to the syslog "
              "(Default: {def_slf}).".
              format(def_slf=DEFAULT_SYSLOG_FACILITY))
@click.option('--pdb', is_flag=True, hidden=True,
              help=u'Break execution in the pdb debugger just before '
                   u'executing the command within zhmc.')
@click.version_option(
    message='%(prog)s, version %(version)s\n' + ZHMCCLIENT_VERSION,
    help="Show the versions of this command and of the zhmcclient package and "
    "exit.")
@click.pass_context
def cli(ctx, host, userid, password, no_verify, ca_certs, output_format,
        transpose, error_format, timestats, log, log_dest, syslog_facility,
        pdb):
    """
    Command line interface for the IBM Z HMC.

    The options shown in this help text are general options that can also
    be specified on any of the (sub-)commands.
    """

    # Concept: In interactive mode, the global options specified in the command
    # line are used as defaults for the commands that are issued interactively.
    # The interactive commands may override these options.
    # This requires being able to determine for each option whether it has been
    # specified. This is the reason the options don't define defaults in the
    # decorators that define them.

    if ctx.obj is None:
        # We are in command mode or are processing the command line options in
        # interactive mode.
        # We apply the documented option defaults.
        if output_format is None:
            output_format = DEFAULT_OUTPUT_FORMAT
        if transpose is None:
            transpose = False
        if error_format is None:
            error_format = DEFAULT_ERROR_FORMAT
        if timestats is None:
            timestats = DEFAULT_TIMESTATS
        if no_verify is None:
            no_verify = DEFAULT_NO_VERIFY
    else:
        # We are processing an interactive command.
        # We apply the option defaults from the command line options.
        if host is None:
            host = ctx.obj.host
        if userid is None:
            userid = ctx.obj.userid
        if password is None:
            # pylint: disable=protected-access
            password = ctx.obj._password
        if no_verify is None:
            no_verify = ctx.obj.no_verify
        if ca_certs is None:
            ca_certs = ctx.obj.ca_certs
        if output_format is None:
            output_format = ctx.obj.output_format
        if transpose is None:
            transpose = ctx.obj.transpose
        if error_format is None:
            error_format = ctx.obj.error_format
        if timestats is None:
            timestats = ctx.obj.timestats
        if pdb is None:
            pdb = ctx.obj.pdb

    if transpose and output_format == 'json':
        raise click_exception(
            "Transposing output tables (-x / --transpose) conflicts with "
            "non-table output format (-o / --output-format): {of}".
            format(of=output_format),
            error_format)

    if no_verify and ca_certs:
        raise click_exception(
            "Disabling HMC certificate verification (-n / --no-verify / "
            "ZHMC_NO_VERIFY) conflicts with specifying a CA certificate path "
            "(-c / --ca-certs / ZHMC_CA_CERTS)",
            error_format)

    # TODO: Add context support for the following options:
    if log is None:
        log = DEFAULT_LOG
    if log_dest is None:
        log_dest = DEFAULT_LOG_DESTINATION
    if syslog_facility is None:
        syslog_facility = DEFAULT_SYSLOG_FACILITY

    # Now we have the effective values for the options as they should be used
    # by the current command, regardless of the mode.

    # Set up logging

    for lc in LOG_COMPONENTS:
        reset_logger(lc)

    if log_dest == 'none':
        handler = None
    elif log_dest == 'syslog':
        # The choices in SYSLOG_FACILITIES have been validated by click
        # so we don't need to further check them.
        facility = SysLogHandler.facility_names[syslog_facility]
        system = platform.system()
        if system.startswith('CYGWIN_NT'):
            # Value is 'CYGWIN_NT-6.1'; strip off trailing version:
            system = 'CYGWIN_NT'
        try:
            addresses = SYSLOG_ADDRESSES[system]
        except KeyError:
            raise NotImplementedError(
                "Logging to syslog is not supported on this platform: {p}".
                format(p=system))
        assert isinstance(addresses, list)
        for address in addresses:
            try:
                handler = SysLogHandler(address=address, facility=facility)
            except Exception:  # pylint: disable=broad-except
                continue
            break
        else:
            exc = sys.exc_info()[1]
            exc_name = exc.__class__.__name__ if exc else None
            raise RuntimeError(
                "Creating SysLogHandler with addresses {al!r} failed. "
                "Failure on last address {a!r} was: {exc}: {msg}".
                format(al=addresses, a=address, exc=exc_name, msg=exc))
        fs = '%(levelname)s %(name)s: %(message)s'
        handler.setFormatter(logging.Formatter(fs))
    elif log_dest == 'stderr':
        handler = StreamHandler(stream=sys.stderr)
        fs = '%(levelname)s %(name)s: %(message)s'
        handler.setFormatter(logging.Formatter(fs))
    else:
        # log_dest is the path name of the log file
        try:
            handler = logging.FileHandler(log_dest)
        except OSError as exc:
            raise click_exception(
                "Cannot log to file {fn}: {exc}: {msg}".
                format(fn=log_dest, exc=exc.__class__.__name__, msg=exc),
                error_format)
        fs = '%(levelname)s %(name)s: %(message)s'
        handler.setFormatter(logging.Formatter(fs))

    log_specs = log.split(',')
    for log_spec in log_specs:

        # ignore extra ',' at begin, end or in between
        if log_spec == '':
            continue

        try:
            log_comp, log_level = log_spec.split('=', 1)
        except ValueError:
            raise click_exception("Missing '=' in COMP=LEVEL specification in "
                                  "--log option: {ls}".format(ls=log_spec),
                                  error_format)

        level = getattr(logging, log_level.upper(), None)
        if level is None:
            raise click_exception("Invalid log level in COMP=LEVEL "
                                  "specification in --log option: {ls}".
                                  format(ls=log_spec),
                                  error_format)

        if log_comp not in LOG_COMPONENTS:
            raise click_exception("Invalid log component in COMP=LEVEL "
                                  "specification in --log option: {ls}".
                                  format(ls=log_spec), error_format)

        if handler:
            setup_logger(log_comp, handler, level)

    session_id = os.environ.get('ZHMC_SESSION_ID', None)
    if session_id and session_id.startswith('faked_session:'):
        # This should be used by the zhmc function tests only.
        # A SyntaxError raised by an incorrect expression is considered
        # an internal error in the function tests and is therefore not
        # handled.
        expr = session_id.split(':', 1)[1]
        faked_session = eval(expr)  # pylint: disable=eval-used
        assert isinstance(faked_session, zhmcclient_mock.FakedSession)
        session_id = faked_session

    def get_password_via_prompt(host, userid):
        """
        Password retrieval function that prompts for the password.

        It follows the interface defined in
        :func:`~zhmcclient.get_password_interface` and needs access to the
        click context (ctx).
        """
        if userid is not None and host is not None:
            ctx.obj.spinner.stop()
            password = click.prompt(
                "Enter password (for user {userid} at HMC {host})".
                format(userid=userid, host=host), hide_input=True,
                confirmation_prompt=False, type=str, err=True)
            ctx.obj.spinner.start()
            return password

        raise click_exception("{cmd} command requires logon, but no "
                              "session-id or userid provided.".
                              format(cmd=ctx.invoked_subcommand),
                              error_format)

    # We create a command context for each command: An interactive command has
    # its own command context different from the command context for the
    # command line.
    ctx.obj = CmdContext(host, userid, password, no_verify, ca_certs,
                         output_format, transpose, error_format, timestats,
                         session_id, get_password_via_prompt, pdb)

    # Invoke default command
    if ctx.invoked_subcommand is None:
        ctx.invoke(repl)


def reset_logger(log_comp):
    """
    Reset the logger for the specified log component to have exactly one
    NullHandler.
    """

    name = LOGGER_NAMES[log_comp]
    logger = logging.getLogger(name)

    has_nh = False
    for h in logger.handlers:
        if not has_nh and isinstance(h, NullHandler):
            has_nh = True
            continue
        logger.removeHandler(h)

    if not has_nh:
        nh = NullHandler()
        logger.addHandler(nh)


def setup_logger(log_comp, handler, level):
    """
    Setup the logger for the specified log component to add the specified
    handler and to set it to the specified log level.
    """

    name = LOGGER_NAMES[log_comp]
    logger = logging.getLogger(name)

    logger.addHandler(handler)
    logger.setLevel(level)


@cli.command('help')
@click.pass_context
def repl_help(ctx):
    # pylint: disable=unused-argument
    """
    Show help message for interactive mode.

    Parameters:

      ctx (:class:`click.Context`): The click context object. Created by the
        ``@click.pass_context`` decorator.
    """
    print("""
The following can be entered in interactive mode:

  <zhmc-cmd>                  Execute zhmc command <zhmc-cmd>.
  !<shell-cmd>                Execute shell command <shell-cmd>.

  <CTRL-D>, :q, :quit, :exit  Exit interactive mode.

  <TAB>                       Tab completion (can be used anywhere).
  --help                      Show zhmc general help message, including a list
                              of zhmc commands.
  <zhmc-cmd> --help           Show help message for zhmc command <zhmc-cmd>.
  help                        Show this help message.
  :?, :h, :help               Show (incomplete) help message about interactive
                              mode.
""")


@cli.command('repl')
@click.pass_context
def repl(ctx):
    """
    Enter interactive (REPL) mode (default).

    Parameters:

      ctx (:class:`click.Context`): The click context object. Created by the
        ``@click.pass_context`` decorator.
    """

    history_file = REPL_HISTORY_FILE
    if history_file.startswith('~'):
        history_file = os.path.expanduser(history_file)

    print("Enter 'help' for help, <CTRL-D> or ':q' to exit.")

    prompt_kwargs = {
        'message': REPL_PROMPT,
        'history': FileHistory(history_file),
    }
    click_repl.repl(ctx, prompt_kwargs=prompt_kwargs)


# TODO: Apparently registering is not needed, clarify that.
# click_repl.register_repl(repl)