diff --git a/tests/unit/test_helper.py b/tests/unit/test_helper.py index 2b5d8330..017158b2 100644 --- a/tests/unit/test_helper.py +++ b/tests/unit/test_helper.py @@ -17,6 +17,7 @@ """ +import os import re import pytest import click @@ -25,7 +26,8 @@ from zhmccli._helper import CmdContext, parse_yaml_flow_style, \ parse_ec_levels, parse_adapter_names, parse_crypto_domains, \ - domains_to_domain_config, domain_config_to_props_list + domains_to_domain_config, domain_config_to_props_list, \ + required_option, forbidden_option, required_envvar, bool_envvar # Test cases for parse_yaml_flow_style() @@ -99,8 +101,8 @@ def test_parse_yaml_flow_style(value, exp_obj, exp_exc_msg): cmd_ctx = CmdContext( host='host', userid='host', password='password', no_verify=True, - ca_certs=None, output_format='table', transpose=False, - error_format='msg', timestats=False, session_id=None, + ca_certs=None, session_name=None, output_format='table', + transpose=False, error_format='msg', timestats=False, session_id=None, get_password=None, pdb=False) # nosec: B106 if exp_exc_msg: @@ -190,8 +192,8 @@ def test_parse_ec_levels(value, exp_obj, exp_exc_msg): cmd_ctx = CmdContext( host='host', userid='host', password='password', no_verify=True, - ca_certs=None, output_format='table', transpose=False, - error_format='msg', timestats=False, session_id=None, + ca_certs=None, session_name=None, output_format='table', + transpose=False, error_format='msg', timestats=False, session_id=None, get_password=None, pdb=False) # nosec: B106 if exp_exc_msg: @@ -285,8 +287,8 @@ def test_parse_adapter_names(value, exp_obj, exp_exc_msg): cmd_ctx = CmdContext( host='host', userid='host', password='password', no_verify=True, - ca_certs=None, output_format='table', transpose=False, - error_format='msg', timestats=False, session_id=None, + ca_certs=None, session_name=None, output_format='table', + transpose=False, error_format='msg', timestats=False, session_id=None, get_password=None, pdb=False) # nosec: B106 if exp_exc_msg: @@ -437,8 +439,8 @@ def test_parse_crypto_domains(value, exp_obj, exp_exc_msg): cmd_ctx = CmdContext( host='host', userid='host', password='password', no_verify=True, - ca_certs=None, output_format='table', transpose=False, - error_format='msg', timestats=False, session_id=None, + ca_certs=None, session_name=None, output_format='table', + transpose=False, error_format='msg', timestats=False, session_id=None, get_password=None, pdb=False) # nosec: B106 if exp_exc_msg: @@ -742,3 +744,339 @@ def test_domain_config_to_props_list( adapters, 'adapter', domain_configs) assert props_list == exp_props_list + + +# Test cases for required_option() +TESTCASES_REQUIRED_OPTION = [ + # value, name, exp_value, exp_exc_msg + ( + 'v1', + 'o1', + 'v1', + None + ), + ( + '', + 'o1', + '', + None + ), + ( + None, + 'o1', + None, + "Required option not specified: o1" + ), +] + + +@pytest.mark.parametrize( + "value, name, exp_value, exp_exc_msg", + TESTCASES_REQUIRED_OPTION) +def test_required_option(value, name, exp_value, exp_exc_msg): + """ + Test function for required_option(). + """ + + if exp_exc_msg: + with pytest.raises(click.exceptions.ClickException) as exc_info: + + # The function to be tested + required_option(value, name) + + exc = exc_info.value + msg = str(exc) + m = re.match(exp_exc_msg, msg) + assert m, \ + "Unexpected exception message:\n" \ + " expected pattern: {!r}\n" \ + " actual message: {!r}".format(exp_exc_msg, msg) + else: + + # The function to be tested + act_value = required_option(value, name) + + assert act_value == exp_value + + +# Test cases for forbidden_option() +TESTCASES_FORBIDDEN_OPTION = [ + # value, name, reason, exp_exc_msg + ( + None, + 'o1', + 'of some reason', + None + ), + ( + '', + 'o1', + 'of some reason', + "Option is not allowed because of some reason: o1" + ), + ( + 'v1', + 'o1', + 'of some reason', + "Option is not allowed because of some reason: o1" + ), +] + + +@pytest.mark.parametrize( + "value, name, reason, exp_exc_msg", + TESTCASES_FORBIDDEN_OPTION) +def test_forbidden_option(value, name, reason, exp_exc_msg): + """ + Test function for forbidden_option(). + """ + + if exp_exc_msg: + with pytest.raises(click.exceptions.ClickException) as exc_info: + + # The function to be tested + forbidden_option(value, name, reason) + + exc = exc_info.value + msg = str(exc) + m = re.match(exp_exc_msg, msg) + assert m, \ + "Unexpected exception message:\n" \ + " expected pattern: {!r}\n" \ + " actual message: {!r}".format(exp_exc_msg, msg) + else: + + # The function to be tested + forbidden_option(value, name, reason) + + +# Test cases for required_envvar() +TESTCASES_REQUIRED_ENVVAR = [ + # initial_value, name, exp_exc_msg + ( + None, + 'e1', + "Required environment variable not set: e1" + ), + ( + '', + 'e1', + None + ), + ( + 'v1', + 'e1', + None + ), +] + + +@pytest.mark.parametrize( + "initial_value, name, exp_exc_msg", + TESTCASES_REQUIRED_ENVVAR) +def test_required_envvar(initial_value, name, exp_exc_msg): + """ + Test function for required_envvar(). + """ + + if initial_value is not None: + os.environ[name] = initial_value + else: + if name in os.environ: + del os.environ[name] + + if exp_exc_msg: + with pytest.raises(click.exceptions.ClickException) as exc_info: + + # The function to be tested + required_envvar(name) + + exc = exc_info.value + msg = str(exc) + m = re.match(exp_exc_msg, msg) + assert m, \ + "Unexpected exception message:\n" \ + " expected pattern: {!r}\n" \ + " actual message: {!r}".format(exp_exc_msg, msg) + else: + + # The function to be tested + value = required_envvar(name) + + assert value == initial_value + + +# Test cases for bool_envvar() +TESTCASES_BOOL_ENVVAR = [ + # initial_value, name, default, exp_value, exp_exc_msg + ( + None, + 'e1', + 'd1', + 'd1', + None, + ), + ( + None, + 'e1', + None, + None, + None, + ), + ( + '0', + 'e1', + 'd1', + False, + None + ), + ( + 'no', + 'e1', + 'd1', + False, + None + ), + ( + 'No', + 'e1', + 'd1', + False, + None + ), + ( + 'NO', + 'e1', + 'd1', + False, + None + ), + ( + 'false', + 'e1', + 'd1', + False, + None + ), + ( + 'False', + 'e1', + 'd1', + False, + None + ), + ( + 'FALSE', + 'e1', + 'd1', + False, + None + ), + ( + '1', + 'e1', + 'd1', + True, + None + ), + ( + 'yes', + 'e1', + 'd1', + True, + None + ), + ( + 'Yes', + 'e1', + 'd1', + True, + None + ), + ( + 'YES', + 'e1', + 'd1', + True, + None + ), + ( + 'true', + 'e1', + 'd1', + True, + None + ), + ( + 'True', + 'e1', + 'd1', + True, + None + ), + ( + 'TRUE', + 'e1', + 'd1', + True, + None + ), + ( + '', + 'e1', + 'd1', + None, + "Invalid value for e1 environment variable: '' is not a valid boolean." + ), + ( + '2', + 'e1', + 'd1', + None, + "Invalid value for e1 environment variable: '2' is not a " + "valid boolean." + ), + ( + 'yesssir', + 'e1', + 'd1', + None, + "Invalid value for e1 environment variable: 'yesssir' is not a " + "valid boolean." + ), +] + + +@pytest.mark.parametrize( + "initial_value, name, default, exp_value, exp_exc_msg", + TESTCASES_BOOL_ENVVAR) +def test_bool_envvar(initial_value, name, default, exp_value, exp_exc_msg): + """ + Test function for bool_envvar(). + """ + + if initial_value is not None: + os.environ[name] = initial_value + else: + if name in os.environ: + del os.environ[name] + + if exp_exc_msg: + with pytest.raises(click.exceptions.ClickException) as exc_info: + + # The function to be tested + bool_envvar(name, default) + + exc = exc_info.value + msg = str(exc) + m = re.match(exp_exc_msg, msg) + assert m, \ + "Unexpected exception message:\n" \ + " expected pattern: {!r}\n" \ + " actual message: {!r}".format(exp_exc_msg, msg) + else: + + # The function to be tested + value = bool_envvar(name, default) + + assert value == exp_value diff --git a/zhmccli/_cmd_cpc.py b/zhmccli/_cmd_cpc.py index 0374554e..26a052c0 100644 --- a/zhmccli/_cmd_cpc.py +++ b/zhmccli/_cmd_cpc.py @@ -934,7 +934,7 @@ def cmd_dpm_export(cmd_ctx, cpc_name, options): client = zhmcclient.Client(cmd_ctx.session) cpc = find_cpc(cmd_ctx, client, cpc_name) - dpm_file = required_option(options, 'dpm_file') + dpm_file = required_option(options['dpm_file'], '--dpm-file') dpm_format = options['dpm_format'] include_unused_adapters = options['include_unused_adapters'] @@ -971,7 +971,7 @@ def cmd_dpm_import(cmd_ctx, cpc_name, options): # process options first, without the spinner running cmd_ctx.spinner.stop() - dpm_file = required_option(options, 'dpm_file') + dpm_file = required_option(options['dpm_file'], '--dpm-file') dpm_format = options['dpm_format'] mapping_file = options['mapping_file'] preserve_uris = options['preserve_uris'] diff --git a/zhmccli/_cmd_session.py b/zhmccli/_cmd_session.py index ef603845..3a2094e3 100644 --- a/zhmccli/_cmd_session.py +++ b/zhmccli/_cmd_session.py @@ -21,13 +21,47 @@ import zhmcclient from .zhmccli import cli -from ._helper import click_exception +from ._helper import click_exception, print_dicts +from ._session_file import HMCSession, HMCSessionFile, SESSION_FILE, \ + DEFAULT_SESSION_NAME, HMCSessionAlreadyExists, BLANKED_OUT_STRING @cli.group('session') def session_group(): """ - Command group for managing sessions. + Command group for managing permanent HMC sessions. + + zhmc commands can by used with a temporary HMC session that is created + and deleted for each command execution, or with a permanent HMC session + that exists across zhmc commands. + + A permanent HMC session can be created in two ways: + + \b + * Using 'zhmc session logon'. This persists the session data in an HMC + session file. The HMC session file is located in the user's home + directory and has file permissions that allow access only for the user. + It contains the session ID, but not the password. + * Deprecated: Using 'zhmc session create'. This displays commands for + setting ZHMC_* environment variables that persist the session data. + These environment variables contain the session ID, but not the password. + This command is deprecated, use 'zhmc session logon' instead. + + There are three ways how session data can be provided to any zhmc command. + In order of decreasing priority, they are: + + \b + * Command line options. This creates a temporary session and is used if + '--host' is specified. + * Environment variables. This uses the permanent session defined in the + ZHMC_* environment variables and is used if the ZHMC_HOST environment + variable is set. + * HMC session file. This uses a permanent session defined in the HMC + session file and is used if none of the above is used. + + The HMC session file can store multiple sessions that are selected using + the `-s` / `--session-name` option. If that option is not specified, a + default session named 'default' is used. In addition to the command-specific options shown in this help text, the general options (see 'zhmc --help') can also be specified before the @@ -35,11 +69,47 @@ def session_group(): """ +@session_group.command('logon') +@click.pass_obj +def session_logon(cmd_ctx): + """ + Log on to the HMC and store the resulting session data in the HMC session + file for use by subsequent commands. + + In addition to the command-specific options shown in this help text, the + general options (see 'zhmc --help') can also be specified before the + command. + """ + cmd_ctx.execute_cmd(lambda: cmd_logon(cmd_ctx), logoff=False) + + +@session_group.command('logoff') +@click.pass_obj +def session_logoff(cmd_ctx): + """ + Log off from the HMC and delete the correspondig session data from the HMC + session file. + + In addition to the command-specific options shown in this help text, the + general options (see 'zhmc --help') can also be specified before the + command. + """ + cmd_ctx.execute_cmd(lambda: cmd_logoff(cmd_ctx)) + + @session_group.command('create') @click.pass_obj def session_create(cmd_ctx): """ - Create an HMC session. + Deprecated: Log on to the HMC and display commands to set environment + variables for use by subsequent commands. + + This can be used for example with the 'eval' function of the bash shell + as follows, to immediately set the resulting environment variables: + + eval $(zhmc ... session create) + + This command is deprecated. Use 'zhmc session logon' instead. In addition to the command-specific options shown in this help text, the general options (see 'zhmc --help') can also be specified before the @@ -52,7 +122,15 @@ def session_create(cmd_ctx): @click.pass_obj def session_delete(cmd_ctx): """ - Delete the current HMC session. + Deprecated: Log off from the HMC and display commands to unset environment + variables that were used by other commands. + + This can be used for example with the 'eval' function of the bash shell + as follows, to immediately unset the resulting environment variables: + + eval $(zhmc session delete) + + This command is deprecated. Use 'zhmc session logoff' instead. In addition to the command-specific options shown in this help text, the general options (see 'zhmc --help') can also be specified before the @@ -61,8 +139,57 @@ def session_delete(cmd_ctx): cmd_ctx.execute_cmd(lambda: cmd_session_delete(cmd_ctx)) +@session_group.command('list') +@click.pass_obj +def session_list(cmd_ctx): + """ + List the sessions in the HMC session file. + + In addition to the command-specific options shown in this help text, the + general options (see 'zhmc --help') can also be specified before the + command. + """ + cmd_ctx.execute_cmd(lambda: cmd_session_list(cmd_ctx), ignore_sessions=True) + + +def cmd_logon(cmd_ctx): + """Log on to the HMC, with session file.""" + session = cmd_ctx.session + try: + session.logon(verify=True) + except zhmcclient.Error as exc: + raise click_exception(exc, cmd_ctx.error_format) + + session_file = HMCSessionFile(SESSION_FILE) + session_name = cmd_ctx.session_name or DEFAULT_SESSION_NAME + hmc_session = HMCSession.from_zhmcclient_session(session) + try: + session_file.add(session_name, hmc_session) + except HMCSessionAlreadyExists: + session_file.update(session_name, hmc_session.as_dict()) + + cmd_ctx.spinner.stop() + print(f"Logged on to HMC session {session_name}") + + +def cmd_logoff(cmd_ctx): + """Log off from the HMC, with session file.""" + session = cmd_ctx.session + try: + session.logoff() + except zhmcclient.Error as exc: + raise click_exception(exc, cmd_ctx.error_format) + + session_file = HMCSessionFile(SESSION_FILE) + session_name = cmd_ctx.session_name or DEFAULT_SESSION_NAME + session_file.remove(session_name) + + cmd_ctx.spinner.stop() + print(f"Logged off from HMC session {session_name}") + + def cmd_session_create(cmd_ctx): - """Create an HMC session.""" + """Log on to the HMC, with environment variables.""" session = cmd_ctx.session try: # We need to first log off, to make the logon really create a new @@ -99,7 +226,7 @@ def cmd_session_create(cmd_ctx): def cmd_session_delete(cmd_ctx): - """Delete the current HMC session.""" + """Log off from the HMC, with environment variables.""" session = cmd_ctx.session try: session.logoff() @@ -107,4 +234,31 @@ def cmd_session_delete(cmd_ctx): raise click_exception(exc, cmd_ctx.error_format) cmd_ctx.spinner.stop() + click.echo("unset ZHMC_HOST") + click.echo("unset ZHMC_USERID") click.echo("unset ZHMC_SESSION_ID") + click.echo("unset ZHMC_NO_VERIFY") + click.echo("unset ZHMC_CA_CERTS") + + +def cmd_session_list(cmd_ctx): + """List the sessions in the HMC session file.""" + session_file = HMCSessionFile(SESSION_FILE) + hmc_sessions = session_file.list() + _session_list = [] + for session_name, session in hmc_sessions.items(): + session_props = {} + session_props['session_name'] = session_name + session_props.update(session.as_dict()) + if session_props['session_id']: + session_props['session_id'] = BLANKED_OUT_STRING + _session_list.append(session_props) + + cmd_ctx.spinner.stop() + show_list = [ + 'session_name', 'host', 'userid', 'ca_verify', 'ca_cert_path', + 'session_id' + ] + + print_dicts(cmd_ctx, _session_list, cmd_ctx.output_format, + show_list=show_list, all=True) diff --git a/zhmccli/_helper.py b/zhmccli/_helper.py index 86a2b4a9..7a0352f4 100644 --- a/zhmccli/_helper.py +++ b/zhmccli/_helper.py @@ -33,6 +33,8 @@ import zhmcclient import zhmcclient_mock +from ._session_file import HMCSessionNotFound, HMCSessionFile, SESSION_FILE + # HMC API versions for new HMC versions # Can be used for comparison with Client.version_info() API_VERSION_HMC_2_11_1 = (1, 1) @@ -222,13 +224,14 @@ class CmdContext: """ def __init__(self, host, userid, password, no_verify, ca_certs, - output_format, transpose, error_format, timestats, session_id, - get_password, pdb): + session_name, output_format, transpose, error_format, + timestats, session_id, get_password, pdb): self._host = host self._userid = userid self._password = password self._no_verify = no_verify self._ca_certs = ca_certs + self._session_name = session_name self._output_format = output_format self._transpose = transpose self._error_format = error_format @@ -243,6 +246,7 @@ def __repr__(self): ret = "CmdContext(at 0x{ctx:08x}, host={s._host!r}, " \ "userid={s._userid!r}, password={pw!r}, " \ "no_verify={s._no_verify!r}, ca_certs={s._ca_certs!r}, " \ + "session_name={s._session_name!r}, " \ "output_format={s._output_format!r}, transpose={s._transpose!r}, " \ "error_format={s._error_format!r}, timestats={s._timestats!r}," \ "session_id={s._session_id!r}, session={s._session!r}, ...)". \ @@ -280,6 +284,13 @@ def ca_certs(self): """ return self._ca_certs + @property + def session_name(self): + """ + :term:`string`: Session name for the HMC session file. + """ + return self._session_name + @property def output_format(self): """ @@ -352,17 +363,23 @@ def pdb(self): """ return self._pdb - def execute_cmd(self, cmd, logoff=True): + def execute_cmd(self, cmd, logoff=True, ignore_sessions=False): """ Execute the command. + + Parameters: + logoff (bool): Log off at the end of the command. + ignore_sessions (bool): Execute the command without any session setup. """ - if self._session is None: + if self._session is None and not ignore_sessions: + session_file = HMCSessionFile(SESSION_FILE) + try: + hmc_session = session_file.get(self._session_name) + except HMCSessionNotFound: + hmc_session = None if isinstance(self._session_id, zhmcclient_mock.FakedSession): self._session = self._session_id - else: - if self._host is None: - raise click_exception("No HMC host provided", - self._error_format) + elif self._host: if self._no_verify: verify_cert = False elif self._ca_certs is None: @@ -374,10 +391,24 @@ def execute_cmd(self, cmd, logoff=True): session_id=self._session_id, get_password=self._get_password, verify_cert=verify_cert) + elif hmc_session: + verify_cert = hmc_session.ca_verify + if verify_cert and hmc_session.ca_cert_path: + verify_cert = hmc_session.ca_cert_path + self._session = zhmcclient.Session( + hmc_session.host, hmc_session.userid, + session_id=hmc_session.session_id, + verify_cert=verify_cert) + else: + raise click_exception( + "No HMC host or session in HMC session file provided", + self._error_format) + + if self._session: + saved_session_id = self._session.session_id + if self.timestats: + self._session.time_stats_keeper.enable() - saved_session_id = self._session.session_id - if self.timestats: - self._session.time_stats_keeper.enable() if not self.pdb: self.spinner.start() @@ -394,15 +425,16 @@ def execute_cmd(self, cmd, logoff=True): finally: if not self.pdb: self.spinner.stop() - if self._session.time_stats_keeper.enabled: - click.echo(self._session.time_stats_keeper) - if logoff: - # We are supposed to log off, but only if the session ID - # was created or renewed by the command execution. We determine - # that by comparing the current session ID it with the saved - # session ID. - if self._session.session_id != saved_session_id: - self._session.logoff() + if self._session: + if self._session.time_stats_keeper.enabled: + click.echo(self._session.time_stats_keeper) + if logoff: + # We are supposed to log off, but only if the session ID + # was created or renewed by the command execution. We + # determine that by comparing the current session ID it + # with the saved session ID. + if self._session.session_id != saved_session_id: + self._session.logoff() def original_options(options): @@ -1611,19 +1643,87 @@ def storage_group_by_uri(self, storage_group_uri): # templates implemented in zhmcclient -def required_option(options, option_key, unspecified_value=None): +def required_option(value, name): """ Check if an option is specified. If it is specified, return the option value. Otherwise, raise ClickException with an according error message. + + Parameters: + value (obj): Option value. `None` indicates it is not specified. + name (str): Long option name, including the leading '--'. + + Returns: + str: Variable value. + """ + if value is None: + raise click.ClickException( + f"Required option not specified: {name}") + return value + + +def forbidden_option(value, name, reason): + """ + Raise ClickException to indicate that an option is not allowed. + + Parameters: + value (obj): Option value. `None` indicates it is not specified. + name (str): Long option name, including the leading '--'. + reason (str): Reason why it is not allowed. This is used after 'because'. + """ + if value is not None: + raise click.ClickException( + f"Option is not allowed because {reason}: {name}") + + +def required_envvar(name): + """ + Check if an environment variable is set. + + If it is set, return its value. + + Otherwise, raise ClickException with an according error message. + + Parameters: + name (str): Variable name. + + Returns: + str: Variable value. """ - if options[option_key] != unspecified_value: - return options[option_key] - option_name = '--' + option_key.replace('_', '-') + value = os.getenv(name, None) + if value is None: + raise click.ClickException( + f"Required environment variable not set: {name}") + return value + + +def bool_envvar(name, default=None): + """ + Return the value of a boolean environment variable. + + If it is not set, return the default value. + + Otherwise, raise ClickException with an according error message. + + Parameters: + name (str): Variable name. + + Returns: + bool: Boolean value of the environment variable. + """ + value = os.getenv(name, None) + if value is None: + return default + value_lower = value.lower() + if value_lower in ('0', 'no', 'false'): + return False + if value_lower in ('1', 'yes', 'true'): + return True raise click.ClickException( - f"Required option not specified: {option_name}") + f"Invalid value for {name} environment variable: '{value}' is not " + "a valid boolean.") def validate(data, schema, what): diff --git a/zhmccli/_session_file.py b/zhmccli/_session_file.py new file mode 100644 index 00000000..9cd32f4b --- /dev/null +++ b/zhmccli/_session_file.py @@ -0,0 +1,416 @@ +# Copyright 2024 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. + +""" +Support for an HMC session file in YAML format. + +An HMC session file stores session-related data about logged-in HMC sessions. +""" + +import os +import stat +from copy import deepcopy +import errno +import yaml +import jsonschema + +__all__ = ['HMCSessionFileError', 'HMCSessionFile', 'HMCSessionException', + 'HMCSessionNotFound', 'HMCSessionAlreadyExists', + 'HMCSessionFileNotFound', 'HMCSessionFileError', + 'HMCSessionFileFormatError'] + + +BLANKED_OUT_STRING = '********' + +DEFAULT_SESSION_NAME = 'default' + +SESSION_FILE = '~/.zhmc_sessions.yml' + +HMC_SESSION_FILE_SCHEMA = { + "$schema": "http://json-schema.org/draft-07/schema#", + "title": "JSON schema for an HMC session file in YAML format", + "description": "List of logged-in HMC sessions", + "type": "object", + "additionalProperties": False, + "patternProperties": { + "^[a-z0-9_]+$": { + "type": "object", + "additionalProperties": False, + "required": [ + "host", + "userid", + "session_id", + "ca_verify", + "ca_cert_path", + ], + "properties": { + # Top-level group name: + "host": { + "description": "HMC host, as hostname or IP address", + "type": "string", + }, + "userid": { + "description": "HMC userid", + "type": "string", + }, + "session_id": { + "description": "HMC session ID", + "type": "string", + }, + "ca_verify": { + "description": "CA certificate validation is performed", + "type": "boolean", + }, + "ca_cert_path": { + "description": "Path name of CA certificate file or " + "directory", + "type": ["string", "null"], + }, + }, + }, + }, +} + + +class HMCSessionException(Exception): + """ + Base class for errors with the HMC session file. + """ + pass + + +class HMCSessionNotFound(HMCSessionException): + """ + The HMC session with the specified name was not found in the HMC session + file. + """ + pass + + +class HMCSessionAlreadyExists(HMCSessionException): + """ + The HMC session with the specified name already exists in the HMC session + file. + """ + pass + + +class HMCSessionFileNotFound(HMCSessionException): + """ + The HMC session file was not found. + """ + pass + + +class HMCSessionFileError(HMCSessionException): + """ + Error reading or writing the HMC session file. + """ + pass + + +class HMCSessionFileFormatError(HMCSessionException): + """ + Error in the format of the content of the HMC session file. + """ + pass + + +class HMCSession: + """ + Representation of an HMC session in the HMC session file. + """ + + def __init__(self, host, userid, session_id, ca_verify, ca_cert_path): + """ + Parameters: + host (str): HMC host, as hostname or IP address. + userid (str): HMC userid. + session_id (str): HMC session ID. + ca_verify (bool): CA certificate validation is performed. + ca_cert_path (str): Path name of CA certificate file or directory, + or None if the default CA chain is used. + """ + self.host = host + self.userid = userid + self.session_id = session_id + self.ca_verify = ca_verify + self.ca_cert_path = ca_cert_path + + def __repr__(self): + return ( + "HMCSession(" + f"host={self.host!r}, " + f"userid={self.userid!r}, " + f"session_id={BLANKED_OUT_STRING}, " + f"ca_verify={self.ca_verify!r}, " + f"ca_cert_path={self.ca_cert_path!r})") + + @staticmethod + def from_zhmcclient_session(zhmcclient_session): + """ + Return new HMCSession object from a zhmcclient Session. + + Parameters: + zhmcclient_session (zhmcclient.Session): The zhmcclient session. + + Returns: + HMCSession: new HMCSession object. + """ + if zhmcclient_session.verify_cert is False: + ca_verify = False + ca_cert_path = None + elif zhmcclient_session.verify_cert is True: + ca_verify = True + ca_cert_path = None + else: + ca_verify = True + ca_cert_path = zhmcclient_session.verify_cert + return HMCSession( + zhmcclient_session.host, + zhmcclient_session.userid, + zhmcclient_session.session_id, + ca_verify, + ca_cert_path) + + def as_dict(self): + """ + Return the HMC session properties as a dict. + """ + return { + "host": self.host, + "userid": self.userid, + "session_id": self.session_id, + "ca_verify": self.ca_verify, + "ca_cert_path": self.ca_cert_path, + } + + +def session_file_opener(path, flags): + """ + Python opener function for the HMC session file. + """ + return os.open(path, flags, mode=stat.S_IRUSR | stat.S_IWUSR) + + +class DictDot(dict): + """Dict with dot representation.""" + + def __repr__(self): + return '{...}' + + +class HMCSessionFile: + """ + Access to an HMC session file. + """ + + def __init__(self, filepath): + """ + Parameters: + + filepath (str): Path name of the HMC session file. + """ + self._filepath = os.path.expanduser(filepath) + self._data = None # File content, deferred loading + + def __repr__(self): + session_dict = {} + for session_name in self._data.keys(): + session_dict[session_name] = DictDot() + return ( + "HMCSessionFile(" + f"filepath={self.filepath!r}, " + f"data={session_dict!r})") + + @property + def filepath(self): + """ + string: Path name of the HMC session file. + """ + return self._filepath + + def get(self, name): + """ + Get the HMC session with the specified name from the HMC session file. + + Parameters: + name (str): Name of the HMC session. + + Returns: + HMCSession: HMC session. + + Raises: + HMCSessionNotFound: HMC session not found in HMC session file. + """ + if self._data is None: + self._data = self._load() + try: + session_item = self._data[name] + except KeyError: + raise HMCSessionNotFound( + "HMC session not found in HMC session file " + f"{self._filepath!r}: {name!r}") + return HMCSession(**session_item) + + def list(self): + """ + List all HMC sessions in the HMC session file. + + Returns: + dict of session name, HMCSession + """ + if self._data is None: + self._data = self._load() + sessions = {} + for session_name, session_item in self._data.items(): + sessions[session_name] = HMCSession(**session_item) + return sessions + + def add(self, name, session): + """ + Add the specified HMC session with the specified name to the HMC + session file, and update the file. + + Parameters: + name (str): Name of the HMC session. + session (HMCSession): The HMC session to be added. + + Raises: + HMCSessionAlreadyExists: HMC session already exists in HMC session + file. + """ + if self._data is None: + self._data = self._load() + if name in self._data: + raise HMCSessionAlreadyExists( + "HMC session already exists in HMC session file " + f"{self._filepath!r}: {name!r}") + self._data[name] = session.as_dict() + self._save(self._data) + + def remove(self, name): + """ + Remove the specified HMC session from the HMC session file, and update + the file. + + Parameters: + name (str): Name of the HMC session. + + Raises: + HMCSessionNotFound: HMC session not found in HMC session file. + """ + if self._data is None: + self._data = self._load() + try: + del self._data[name] + except KeyError: + raise HMCSessionNotFound( + "HMC session not found in HMC session file " + f"{self._filepath!r}: {name!r}") + self._save(self._data) + + def update(self, name, updates): + """ + Update the specified HMC session with the provided keyword arguments, + and update the file. + + Parameters: + name (str): Name of the HMC session. + updates (dict): Properties of the HMC session to be updated. + + Raises: + HMCSessionNotFound: HMC session not found in HMC session file. + """ + if self._data is None: + self._data = self._load() + data = deepcopy(self._data) + try: + data[name].update(updates) + except KeyError: + raise HMCSessionNotFound( + "HMC session not found in HMC session file " + f"{self._filepath!r}: {name!r}") + self._save(data) + self._data = data + + def _create(self): + """ + Create an empty HMC session file and return its empty data. + """ + + try: + # pylint: disable=unspecified-encoding + with open(self._filepath, 'w', opener=session_file_opener) as fp: + fp.write("") + os.chmod(self._filepath, stat.S_IRUSR | stat.S_IWUSR) + except OSError as exc: + if exc.errno == errno.ENOENT: + raise HMCSessionFileError( + f"The HMC session file {self._filepath!r} could not be " + "created") + raise + return {} + + def _load(self): + """ + Load the HMC session file, validate it and return its data. + """ + try: + # pylint: disable=unspecified-encoding + with open(self._filepath) as fp: + try: + data = yaml.load(fp, Loader=yaml.SafeLoader) + except (yaml.parser.ParserError, + yaml.scanner.ScannerError) as exc: + raise HMCSessionFileFormatError( + "Invalid YAML syntax in HMC session file " + f"{self._filepath!r}: {exc.__class__.__name__} {exc}") + except OSError: + data = self._create() + self._validate(data) + return data + + def _save(self, data): + """ + Validate and save the data to the HMC session file. + """ + self._validate(data) + try: + # pylint: disable=unspecified-encoding + with open(self._filepath, 'w', opener=session_file_opener) as fp: + yaml.dump(data, fp, indent=4, default_flow_style=False) + os.chmod(self._filepath, stat.S_IRUSR | stat.S_IWUSR) + except OSError as exc: + if exc.errno == errno.ENOENT: + raise HMCSessionFileError( + f"The HMC session file {self._filepath!r} could not be " + "written") + raise + + def _validate(self, data): + """ + Validate that the data conforms to the schema for the HMC session file. + """ + try: + jsonschema.validate(data, HMC_SESSION_FILE_SCHEMA) + except jsonschema.exceptions.ValidationError as exc: + elem = '.'.join(str(e) for e in exc.absolute_path) + schemaitem = '.'.join(str(e) for e in exc.absolute_schema_path) + raise HMCSessionFileFormatError( + "Invalid data format in HMC session file " + f"{self._filepath}: {exc.message}; " + f"Offending element: {elem}; " + f"Schema item: {schemaitem}; " + f"Validator: {exc.validator}={exc.validator_value}") diff --git a/zhmccli/zhmccli.py b/zhmccli/zhmccli.py index 9a60c51c..914ab709 100644 --- a/zhmccli/zhmccli.py +++ b/zhmccli/zhmccli.py @@ -30,9 +30,15 @@ import zhmcclient import zhmcclient_mock +from ._session_file import HMCSessionFile, SESSION_FILE, \ + DEFAULT_SESSION_NAME, HMCSessionNotFound + + 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 + SYSLOG_FACILITIES, click_exception, get_click_terminal_width, \ + required_option, forbidden_option, required_envvar, bool_envvar + urllib3.disable_warnings() @@ -87,28 +93,40 @@ @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('-h', '--host', type=str, default=None, + help="Hostname or IP address of the HMC. " + "This option determines where the logon data (except " + "password) comes from: " + "If specified, logon data comes from command line options. " + "Otherwise if the ZHMC_HOST environment variable is set, " + "logon data comes from ZHMC_* environment variables. " + "Otherwise, logon data comes from the HMC session file.") +@click.option('-u', '--userid', type=str, default=None, + help="Username for the HMC. " + "The corresponding environment variable is ZHMC_USERID.") @click.option('-p', '--password', type=str, envvar='ZHMC_PASSWORD', - help="Password for the HMC " - "(Default: ZHMC_PASSWORD environment variable).") + help="Password for the HMC. If not specified, it is taken from " + "the ZHMC_PASSWORD environment variable. If that is also " + "set, it is prompted for. Note that this approach for the " + "password is independent of where the other logon data " + "comes from.") @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', + "The corresponding environment variable is ZHMC_NO_VERIFY, " + "with values 0/no/false or 1/yes/true.") +@click.option('-c', '--ca-certs', type=str, default=None, 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 " + "The corresponding environment variable is ZHMC_CA_CERTS. " + "The empty string causes the path name to be taken from " + "the REQUESTS_CA_BUNDLE environment variable, " + "or from the CURL_CA_BUNDLE environment variable, " + "or the 'certifi' Python package is used which provides the " "Mozilla CA Certificate List).") +@click.option('-s', '--session-name', type=str, metavar='NAME', + default=DEFAULT_SESSION_NAME, + help="Session name when using the HMC session file. " + f"(Default: {DEFAULT_SESSION_NAME})") @click.option('-o', '--output-format', type=click.Choice(TABLE_FORMATS + ['json']), help='Output format (Default: {def_of}).'. @@ -142,9 +160,9 @@ 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): +def cli(ctx, host, userid, password, no_verify, ca_certs, session_name, + output_format, transpose, error_format, timestats, log, log_dest, + syslog_facility, pdb): """ Command line interface for the IBM Z HMC. @@ -163,6 +181,39 @@ def cli(ctx, host, userid, password, no_verify, ca_certs, output_format, # We are in command mode or are processing the command line options in # interactive mode. # We apply the documented option defaults. + HOST_REASON = "--host is not specified" + if host is not None: + # Logon data comes from command line options + userid = required_option(userid, '--userid') + if no_verify is None: + no_verify = DEFAULT_NO_VERIFY + session_id = None + elif os.getenv('ZHMC_HOST') is not None: + # Logon data comes from command ZHMC_* environment variables + forbidden_option(userid, '--userid', HOST_REASON) + forbidden_option(no_verify, '--no-verify', HOST_REASON) + forbidden_option(ca_certs, '--ca-certs', HOST_REASON) + userid = required_envvar('ZHMC_USERID') + no_verify = bool_envvar('ZHMC_NO_VERIFY', DEFAULT_NO_VERIFY) + ca_certs = os.getenv('ZHMC_CA_CERTS', None) + session_id = os.getenv('ZHMC_SESSION_ID', None) + else: + # Logon data comes from HMC session file + forbidden_option(userid, '--userid', HOST_REASON) + forbidden_option(no_verify, '--no-verify', HOST_REASON) + forbidden_option(ca_certs, '--ca-certs', HOST_REASON) + session_file = HMCSessionFile(SESSION_FILE) + try: + hmc_session = session_file.get(session_name) + except HMCSessionNotFound: + raise click.ClickException( + f"Session not found in HMC session file: {session_name}") + host = hmc_session.host + userid = hmc_session.userid + no_verify = not hmc_session.ca_verify + ca_certs = hmc_session.ca_cert_path + session_id = hmc_session.session_id + if output_format is None: output_format = DEFAULT_OUTPUT_FORMAT if transpose is None: @@ -171,8 +222,8 @@ def cli(ctx, host, userid, password, no_verify, ca_certs, output_format, error_format = DEFAULT_ERROR_FORMAT if timestats is None: timestats = DEFAULT_TIMESTATS - if no_verify is None: - no_verify = DEFAULT_NO_VERIFY + if session_name is None: + session_name = DEFAULT_SESSION_NAME else: # We are processing an interactive command. # We apply the option defaults from the command line options. @@ -180,13 +231,15 @@ def cli(ctx, host, userid, password, no_verify, ca_certs, output_format, 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 password is None: + # pylint: disable=protected-access + password = ctx.obj._password + if session_name is None: + session_name = ctx.obj.session_name if output_format is None: output_format = ctx.obj.output_format if transpose is None: @@ -305,7 +358,6 @@ def cli(ctx, host, userid, password, no_verify, ca_certs, output_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 @@ -342,8 +394,8 @@ def get_password_via_prompt(host, userid): # 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) + session_name, output_format, transpose, error_format, + timestats, session_id, get_password_via_prompt, pdb) # Invoke default command if ctx.invoked_subcommand is None: