From cac2c97751bee41931db7bad925404d1592679d2 Mon Sep 17 00:00:00 2001 From: Andreas Maier Date: Sun, 22 Dec 2024 06:37:21 +0100 Subject: [PATCH] Support for logon/logoff and named sessions Details: * Added new 'zhmc session logon/logoff' commands that manage the session in a file '.zhmc_sessions.yaml' in the user's home directory. * Deprecated the existing 'zhmc session create/delete' commands since they are less convenient to use compared to the new logon/logoff commands, and cannot be used as intended on Windows due to the displayed export/unset commands. * Added a general option '-s' / '--session-name' for specifying the name of the session that is managed in the '.zhmc_sessions.yaml' file. This allows having multiple sessions to different HMCs open at the same time, and selecting them by name. If not specified, the session name is 'default'. * The 'session delete' command was changed to unset all ZHMC_* environment variables, instead of just ZHMC_SESSION_ID. * If logon options and ZHMC_* environment variables are both specified, the logon options now have precedence and none of the ZHMC_* environment variables is used (except ZHMC_PASSWORD). This changes the earlier behavior where ZHMC_SESSION_ID was used even when logon options were specified. * The existing testcases for sessions were adjusted for the new behavior. * Added function testcases for the new _session_file.py module. * The end2end testcases for the _cmd_sessions.py module were extended with new testcases. Signed-off-by: Andreas Maier --- Makefile | 3 +- changes/544.2.feature.rst | 7 + changes/544.2.incompatible.rst | 3 + changes/544.deprecation.rst | 4 + changes/544.feature.rst | 6 + changes/544.incompatible.rst | 5 + minimum-constraints-install.txt | 3 +- requirements.txt | 5 +- tests/end2end/test_session.py | 1145 +++++++++++++++++++++------ tests/end2end/utils.py | 37 +- tests/function/test_info.py | 18 +- tests/function/test_session_file.py | 956 ++++++++++++++++++++++ tests/function/utils.py | 10 +- tests/unit/test_helper.py | 376 ++++++++- zhmccli/_cmd_cpc.py | 4 +- zhmccli/_cmd_session.py | 227 +++++- zhmccli/_helper.py | 200 ++++- zhmccli/_session_file.py | 449 +++++++++++ zhmccli/zhmccli.py | 128 ++- 19 files changed, 3205 insertions(+), 381 deletions(-) create mode 100644 changes/544.2.feature.rst create mode 100644 changes/544.2.incompatible.rst create mode 100644 changes/544.deprecation.rst create mode 100644 changes/544.feature.rst create mode 100644 changes/544.incompatible.rst create mode 100644 tests/function/test_session_file.py create mode 100644 zhmccli/_session_file.py diff --git a/Makefile b/Makefile index a5070e5d..9b645d4d 100644 --- a/Makefile +++ b/Makefile @@ -633,7 +633,8 @@ $(done_dir)/ruff_$(pymn)_$(PACKAGE_LEVEL).done: $(done_dir)/develop_$(pymn)_$(PA check_reqs: $(done_dir)/develop_$(pymn)_$(PACKAGE_LEVEL).done minimum-constraints-develop.txt minimum-constraints-install.txt requirements.txt @echo "Makefile: Checking missing dependencies of this package" pip-missing-reqs $(package_name) --requirements-file=requirements.txt - pip-missing-reqs $(package_name) --requirements-file=minimum-constraints-install.txt +# TODO-ZHMC: Enable again, once released version is available +# pip-missing-reqs $(package_name) --requirements-file=minimum-constraints-install.txt @echo "Makefile: Done checking missing dependencies of this package" ifeq ($(PLATFORM),Windows_native) # Reason for skipping on Windows is https://github.com/r1chardj0n3s/pip-check-reqs/issues/67 diff --git a/changes/544.2.feature.rst b/changes/544.2.feature.rst new file mode 100644 index 00000000..55a91605 --- /dev/null +++ b/changes/544.2.feature.rst @@ -0,0 +1,7 @@ +Added support for creating multiple named sessions with a new global option +'-s' / '--session-name'. It is optional and defaults to the name 'default'. +This option can be used with 'zhmc session logon/logoff' to create or delete a +named session, and with any other zhmc command to use a session that has +previously been created. The 'zhmc session create/delete' commands do not +support named sessions, because the environment variables that are used to +store the session data support only a single session. diff --git a/changes/544.2.incompatible.rst b/changes/544.2.incompatible.rst new file mode 100644 index 00000000..38726983 --- /dev/null +++ b/changes/544.2.incompatible.rst @@ -0,0 +1,3 @@ +The 'zhmc session create' command now creates a new session without logging off +from an existing session. Previously, it logged off from an existing session +and then created a new session. diff --git a/changes/544.deprecation.rst b/changes/544.deprecation.rst new file mode 100644 index 00000000..e5c37cdd --- /dev/null +++ b/changes/544.deprecation.rst @@ -0,0 +1,4 @@ +The 'zhmc session create/delete' commands are now deprecated. They were +inconvenient to use and did not support Windows out of the box since they +displayed the export/unset commands to manage the session. Use the new +'zhmc session logon/logoff' commands instead. diff --git a/changes/544.feature.rst b/changes/544.feature.rst new file mode 100644 index 00000000..68821494 --- /dev/null +++ b/changes/544.feature.rst @@ -0,0 +1,6 @@ +New 'zhmc session logon/logoff' commands are provided. They manage the session +in a '.zhmc_sessions.yaml' file in the user's home directory. This is more +convenient for users compared to the existing 'zhmc session create/delete' +commands which store the session in environment variables and display the +export/unset commands to do that. The new commands also support Windows out +of the box. diff --git a/changes/544.incompatible.rst b/changes/544.incompatible.rst new file mode 100644 index 00000000..f50a544d --- /dev/null +++ b/changes/544.incompatible.rst @@ -0,0 +1,5 @@ +If logon options and ZHMC_* environment variables are both provided, the +logon options now take precedence, and the environment variables are ignored. +As a result, a provided ZHMC_SESSION_ID variable is now ignored when logon +options are also provided. Previously, a provided ZHMC_SESSION_ID variable was +used when logon options were also provided. diff --git a/minimum-constraints-install.txt b/minimum-constraints-install.txt index 26db029a..e2bb0172 100644 --- a/minimum-constraints-install.txt +++ b/minimum-constraints-install.txt @@ -12,7 +12,8 @@ wheel==0.41.3 # Direct dependencies for install (must be consistent with requirements.txt) -zhmcclient==1.18.0 +# TODO-ZHMC: Switch to released version again, once available +# zhmcclient==1.19.0 click==8.0.2 click-repl==0.2 diff --git a/requirements.txt b/requirements.txt index 1ed9f8af..63ecc9cb 100644 --- a/requirements.txt +++ b/requirements.txt @@ -6,8 +6,9 @@ # Direct dependencies for install (must be consistent with minimum-constraints-install.txt) -# zhmcclient @ git+https://github.com/zhmcclient/python-zhmcclient.git@master -zhmcclient>=1.18.0 +# TODO-ZHMC: Switch to released version again, once available +zhmcclient @ git+https://github.com/zhmcclient/python-zhmcclient.git@master +# zhmcclient>=1.19.0 # safety 2.2.0 depends on click>=8.0.2 click>=8.0.2 diff --git a/tests/end2end/test_session.py b/tests/end2end/test_session.py index 2ff17ab3..3d1e24c4 100644 --- a/tests/end2end/test_session.py +++ b/tests/end2end/test_session.py @@ -21,12 +21,15 @@ import re import time import urllib3 +import yaml import pytest # pylint: disable=line-too-long,unused-import from zhmcclient.testutils import hmc_definition # noqa: F401, E501 # pylint: enable=line-too-long,unused-import +from zhmccli._session_file import DEFAULT_SESSION_NAME + from .utils import run_zhmc, create_hmc_session, delete_hmc_session, \ is_valid_hmc_session, env2bool @@ -36,8 +39,49 @@ TESTLOG = env2bool('TESTLOG') +def assert_session_logon( + rc, stdout, stderr, env_session_id, sf_session_id, session_name, + result_sf_str, hmc_definition, # noqa: F811 + exp_rc, exp_err, pdb_): + # pylint: disable=redefined-outer-name + """ + Check the result of a 'session logon' command. + """ + assert rc == exp_rc, \ + "Unexpected exit code: got {}, expected {}\nstdout:\n{}\nstderr:\n{}". \ + format(rc, exp_rc, stdout, stderr) + + if pdb_: + # The pdb interactions are also part of the stdout lines, so checking + # stdout does not make sense. + return + + if exp_err: + assert re.search(exp_err, stderr), \ + "Unexpected stderr:\ngot: {}\nexpected pattern: {}". \ + format(stderr, exp_err) + + if rc == 0: + result_sf_data = yaml.load(result_sf_str, Loader=yaml.SafeLoader) + if session_name is None: + session_name = DEFAULT_SESSION_NAME + + assert session_name in result_sf_data + result_sf_dict = result_sf_data[session_name] + result_session_id = result_sf_dict['session_id'] + + assert result_sf_dict['host'] == hmc_definition.host + assert result_sf_dict['userid'] == hmc_definition.userid + assert result_sf_dict['ca_verify'] == hmc_definition.verify + assert result_sf_dict['ca_cert_path'] == hmc_definition.ca_certs + + assert_session_state_logon( + result_session_id, env_session_id, sf_session_id, hmc_definition) + + def assert_session_create( - rc, stdout, stderr, hmc_definition, # noqa: F811 + rc, stdout, stderr, env_session_id, sf_session_id, + hmc_definition, # noqa: F811 exp_rc, exp_err, pdb_): # pylint: disable=redefined-outer-name """ @@ -83,7 +127,7 @@ def assert_session_create( assert zhmc_userid == hmc_definition.userid assert 'ZHMC_SESSION_ID' in export_vars - del export_vars['ZHMC_SESSION_ID'] + result_session_id = export_vars.pop('ZHMC_SESSION_ID') if hmc_definition.verify: assert 'ZHMC_NO_VERIFY' in unset_vars @@ -104,9 +148,71 @@ def assert_session_create( assert not export_vars assert not unset_vars + assert_session_state_logon( + result_session_id, env_session_id, sf_session_id, hmc_definition) + + +def assert_session_state_logon( + result_session_id, env_session_id, sf_session_id, + hmc_definition): # noqa: F811 + # pylint: disable=redefined-outer-name + """ + Verify the session state after the logon. + """ + # If a valid session ID was provided to the command in the env vars, + # verify that it is still valid and that it has not been reused for the + # new session ID. + if env_session_id: + assert is_valid_hmc_session(hmc_definition, env_session_id) + assert result_session_id != env_session_id + + # If a valid session ID was provided to the command in the session file, + # verify that it is still valid and that it has not been reused for the + # new session ID. + if sf_session_id: + assert is_valid_hmc_session(hmc_definition, sf_session_id) + assert result_session_id != sf_session_id + + +def assert_session_logoff( + rc, stdout, stderr, logon_opts, envvars, env_session_id, sf_session_id, + session_name, result_sf_str, + hmc_definition, # noqa: F811 + exp_rc, exp_err, pdb_): + # pylint: disable=redefined-outer-name,unused-argument + """ + Check the result of a 'session logoff' command. + """ + assert rc == exp_rc, \ + "Unexpected exit code: got {}, expected {}\nstdout:\n{}\nstderr:\n{}". \ + format(rc, exp_rc, stdout, stderr) + + if pdb_: + # The pdb interactions are also part of the stdout lines, so checking + # stdout does not make sense. + return + + if exp_err: + assert re.search(exp_err, stderr), \ + "Unexpected stderr:\ngot: {}\nexpected pattern: {}". \ + format(stderr, exp_err) + + if rc == 0: + + # Verify that the session is not in the updated HMC session file. + result_sf_data = yaml.load(result_sf_str, Loader=yaml.SafeLoader) + if session_name is None: + session_name = DEFAULT_SESSION_NAME + assert session_name not in result_sf_data + + assert_session_state_logoff( + logon_opts, envvars, env_session_id, sf_session_id, hmc_definition) + def assert_session_delete( - rc, stdout, stderr, hmc_definition, # noqa: F811 + rc, stdout, stderr, logon_opts, envvars, env_session_id, sf_session_id, + session_name, result_sf_str, + hmc_definition, # noqa: F811 exp_rc, exp_err, pdb_): # pylint: disable=redefined-outer-name,unused-argument """ @@ -143,15 +249,61 @@ def assert_session_delete( continue raise AssertionError(f"Unexpected line on stdout: {line!r}") + assert 'ZHMC_HOST' in unset_vars + del unset_vars['ZHMC_HOST'] + assert 'ZHMC_USERID' in unset_vars + del unset_vars['ZHMC_USERID'] assert 'ZHMC_SESSION_ID' in unset_vars del unset_vars['ZHMC_SESSION_ID'] + assert 'ZHMC_NO_VERIFY' in unset_vars + del unset_vars['ZHMC_NO_VERIFY'] + assert 'ZHMC_CA_CERTS' in unset_vars + del unset_vars['ZHMC_CA_CERTS'] assert not export_vars assert not unset_vars + assert_session_state_logoff( + logon_opts, envvars, env_session_id, sf_session_id, hmc_definition) + + +def assert_session_state_logoff( + logon_opts, envvars, env_session_id, sf_session_id, + hmc_definition): # noqa: F811 + # pylint: disable=redefined-outer-name + """ + Verify the session state after the logoff. + """ + # If a valid session ID was provided to the command in the env vars, + # verify the state of its session on the HMC. + if env_session_id: + if '-h' not in logon_opts and '--host' not in logon_opts and \ + 'ZHMC_HOST' in envvars: + # Logon data came from the env vars. Verify that the session + # has been deleted. + assert not is_valid_hmc_session(hmc_definition, env_session_id) + else: + # Logon data came from the logon options or session file. + # Verify that the session is still valid. + assert is_valid_hmc_session(hmc_definition, env_session_id) + + # If a valid session ID was provided to the command in the session file, + # verify the state of its session on the HMC. + if sf_session_id: + if '-h' not in logon_opts and '--host' not in logon_opts and \ + 'ZHMC_HOST' not in envvars: + # Logon data came from the session file. Verify that the session + # has been deleted. + assert not is_valid_hmc_session(hmc_definition, sf_session_id) + else: + # Logon data came from the logon options or env vars. + # Verify that the session is still valid. + assert is_valid_hmc_session(hmc_definition, sf_session_id) + def assert_session_command( - rc, stdout, stderr, hmc_definition, # noqa: F811 + rc, stdout, stderr, env_session_id, sf_session_id, + hmc_definition, # noqa: F811 exp_rc, exp_err, pdb_): # pylint: disable=redefined-outer-name,unused-argument """ @@ -171,6 +323,16 @@ def assert_session_command( "Unexpected stderr:\ngot: {}\nexpected pattern: {}". \ format(stderr, exp_err) + # If a valid session ID was provided to the command in the env vars, + # verify that that session was not deleted on the HMC + if env_session_id: + assert is_valid_hmc_session(hmc_definition, env_session_id) + + # If a valid session ID was provided to the command in the session file, + # verify that that session was not deleted on the HMC + if sf_session_id: + assert is_valid_hmc_session(hmc_definition, sf_session_id) + def get_session_create_exports(stdout): """ @@ -278,7 +440,6 @@ def prepare_logon_args(logon_opts, hmc_definition): # noqa: F811 logon_args.extend([name, hmc_definition.ca_certs]) elif kind == 'invalid': if not hmc_definition.ca_certs: - # Do it the opposite way -> invalid logon_args.extend([name, 'invalid-cert-path']) else: raise AssertionError( @@ -288,6 +449,49 @@ def prepare_logon_args(logon_opts, hmc_definition): # noqa: F811 return logon_args +def prepare_session_content(sf_str, hmc_definition): # noqa: F811 + # pylint: disable=redefined-outer-name + """ + Update HMC session file content to replace 'valid' with the valid + values from the HMC definition, and 'invalid' with invalid values. + + Parameters: + sf_str (str): HMC session file content, with values that may be + 'valid' or 'invalid'. + hmc_definition (): HMC definition. + + Returns: + str: HMC session file content, with 'valid' and 'invalid' values replaced. + """ + sf_data = yaml.load(sf_str, Loader=yaml.SafeLoader) + for item in sf_data.values(): + if item['host'] == 'valid': + item['host'] = hmc_definition.host + elif item['host'] == 'invalid': + item['host'] = 'invalid-host' + if item['userid'] == 'valid': + item['userid'] = hmc_definition.userid + elif item['userid'] == 'invalid': + item['userid'] = 'invalid-userid' + sf_session_id = None + if item['session_id'] == 'valid': + item['session_id'] = create_hmc_session(hmc_definition) + sf_session_id = item['session_id'] + elif item['session_id'] == 'invalid': + item['session_id'] = 'invalid-session-id' + if item['ca_verify'] == 'valid': + item['ca_verify'] = hmc_definition.verify + elif item['ca_verify'] == 'invalid': + # Do it the opposite way -> invalid + item['ca_verify'] = not hmc_definition.verify + if item['ca_cert_path'] == 'valid': + item['ca_cert_path'] = hmc_definition.ca_certs + elif item['ca_cert_path'] == 'invalid': + item['ca_cert_path'] = 'invalid-cert-path' + sf_str = yaml.dump(sf_data) + return sf_str, sf_session_id + + def test_utils_valid_session(hmc_definition): # noqa: F811 # pylint: disable=redefined-outer-name """ @@ -315,10 +519,10 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 assert is_valid is False -TESTCASE_SESSION_CREATE = [ +TESTCASE_SESSION_LOGON_CREATE = [ - # Each item is a testcase for test_session_create(), with the following - # items: + # Each item is a testcase for test_session_logon() and + # test_session_create(), with the following items: # # * desc (str): Testcase description # * envvars (dict): ZHMC env vars to be set before running command. Key is @@ -331,6 +535,11 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 # - omitted - option is not provided # - 'valid' - option is provided with valid value from HMC definition # - 'invalid' - option is provided with some invalid value + # * session_name (str): Session name in HMC session file to be used, used + # to specify the '-s' option. None means the option is not specified, + # using the default session name. + # * initial_sf_str (str): Initial content of HMC session file to be + # created for the zhmc run. If None, an empty HMC session file is created. # * exp_rc (int): Expected command exit code. # * exp_err (str): Pattern for expected error message, or None for success. # * run: Testcase run control, as follows: @@ -339,12 +548,12 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 # - 'pdb' - debug the testcase (wake up in pdb before command function) # - 'log' - enable HMC logging and display the log records # - 'sleep' - sleep for 60 sec after the testcase (used to circumvent - # temporary disablemnt of logong due to too many logons). + # temporary disablement of logon due to too many logons). ( - "no env vars and valid logon opts", - # Since there is no session ID in the env vars, a new session is created - # on the HMC, using the valid password. + "Logon data from logon opts (no env vars, empty session file)", + # A new session is created from the logon options, using the valid + # password. {}, { '-h': 'valid', @@ -353,14 +562,16 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 '-n': 'valid', '-c': 'valid', }, + None, + None, 0, None, True ), ( - "no env vars and valid logon opts without -c", - # Since there is no session ID in the env vars, a new session is created - # on the HMC, using the valid password. - # Because -n is specified, it does not matter that -c is omitted. + "Logon data from logon opts without -c", + # A new session is created from the logon options, using the valid + # password. Because -n is specified, it does not matter that -c is + # omitted. {}, { '-h': 'valid', @@ -368,13 +579,28 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 '-p': 'valid', '-n': 'valid', }, + None, + None, 0, None, True ), ( - "no env vars and logon opts with invalid pw", - # Since there is no session ID in the env vars, a new session is created - # on the HMC, using the invalid password, which fails with 403,0. + "Logon data from logon opts with missing -u", + # If logon options are used (detected by presence of -h), a userid is + # required. + {}, + { + '-h': 'valid', + }, + None, + None, + 1, "Required option not specified: --userid", + True + ), + ( + "Logon data from logon opts with invalid password", + # A new session is attempted to be created from the logon options, + # which fails due to the invalid password. {}, { '-h': 'valid', @@ -383,25 +609,29 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 '-n': 'valid', '-c': 'valid', }, + None, + None, 1, "ServerAuthError: HTTP authentication failed with 403,0", True ), ( - "just session_id env var (valid session) and no logon opts", + "Just valid session ID in env vars", # A (valid) session ID is in the env vars, but for creating a Session - # oject, an HMC host is also needed. + # object, host and userid are also needed. { 'ZHMC_SESSION_ID': 'valid', }, {}, - 1, 'No HMC host provided', + None, + None, + 1, 'No HMC host or session in HMC session file provided', True ), ( - "all env vars (valid session) and no logon opts", - # The valid session in the env var is successfully deleted on the HMC. - # A new session is created on the HMC, but since no password is - # provided, it is prompted for. + "Logon data from env vars with valid session ID", + # A new session is created from the env vars, using the valid password. + # It does not matter that a valid session ID is specified, because the + # logon happens regardless of that. { 'ZHMC_HOST': 'valid', 'ZHMC_USERID': 'valid', @@ -409,18 +639,17 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 'ZHMC_NO_VERIFY': 'valid', 'ZHMC_CA_CERTS': 'valid', }, - {}, + { + '-p': 'valid', + }, + None, + None, 0, None, - # Disabled this test case because the command prompts for the password. - # TODO: Add support for providing the password to the prompt - False + True ), ( - "all env vars (invalid session) and no logon opts", - # The invalid session in the env var is attempted to be deleted on the - # HMC and the failure of that due to invalid session ID is ignored. - # A new session is created on the HMC, but since no password is - # provided, it is prompted for. + "Logon data from env vars with invalid session ID", + # A new session is created from the env vars, using the valid password. { 'ZHMC_HOST': 'valid', 'ZHMC_USERID': 'valid', @@ -428,16 +657,19 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 'ZHMC_NO_VERIFY': 'valid', 'ZHMC_CA_CERTS': 'valid', }, - {}, + { + '-p': 'valid', + }, + None, + None, 0, None, - # Disabled this test case because the command prompts for the password. - # TODO: Add support for providing the password to the prompt - False + True ), ( - "all env vars (valid session) and valid logon opts", - # The valid session in the env var is successfully deleted on the HMC. - # A new session is created on the HMC, using the valid password. + "Logon data from env vars with invalid password", + # A new session is attempted to be created from the env vars, which + # fails due to the invalid password. It does not matter that a valid + # session ID is specified, because the logon happens regardless of that. { 'ZHMC_HOST': 'valid', 'ZHMC_USERID': 'valid', @@ -446,24 +678,22 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 'ZHMC_CA_CERTS': 'valid', }, { - '-h': 'valid', - '-u': 'valid', - '-p': 'valid', - '-n': 'valid', - '-c': 'valid', + '-p': 'invalid', }, - 0, None, + None, + None, + 1, "ServerAuthError: HTTP authentication failed with 403,0", True ), ( - "all env vars (invalid session) and valid logon opts", - # The invalid session in the env var is attempted to be deleted on the - # HMC, which fails due to being invalid, which is ignored. - # A new session is created on the HMC, using the valid password. + "Logon data from logon opts preceding env vars", + # A new session is created from the logon options, using the valid + # password. The logon options take precedence and the env vars are + # ignored, which is verified by specifying an invalid host. { - 'ZHMC_HOST': 'valid', + 'ZHMC_HOST': 'invalid', 'ZHMC_USERID': 'valid', - 'ZHMC_SESSION_ID': 'invalid', + 'ZHMC_SESSION_ID': 'valid', 'ZHMC_NO_VERIFY': 'valid', 'ZHMC_CA_CERTS': 'valid', }, @@ -474,63 +704,205 @@ def test_utils_invalid_session(hmc_definition): # noqa: F811 '-n': 'valid', '-c': 'valid', }, + None, + None, 0, None, True ), ( - "all env vars (valid session) and logon opts with invalid pw", - # The valid session in the env var is successfully deleted on the HMC. - # A new session is attempted to be created on the HMC, wich fails due - # to the invalid password. + "Logon data from session file with valid session ID", + # A new session is created from the session file, using the valid + # password. It does not matter that a valid session ID is specified, + # because the logon happens regardless of that. + {}, { - 'ZHMC_HOST': 'valid', - 'ZHMC_USERID': 'valid', - 'ZHMC_SESSION_ID': 'valid', - 'ZHMC_NO_VERIFY': 'valid', - 'ZHMC_CA_CERTS': 'valid', + '-p': 'valid', }, + None, + """ +default: + host: valid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", + 0, None, + True + ), + ( + "Logon data from session file with invalid password", + # A new session is attempted to be created from the session file, which + # fails due to the invalid password. It does not matter that a valid + # session ID is specified, because the logon happens regardless of that. + {}, + { + '-p': 'invalid', + }, + None, + """ +default: + host: valid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", + 1, "ServerAuthError: HTTP authentication failed with 403,0", + True + ), + ( + "Logon data from session file with session name", + # A new session is created from the session file, using the valid + # password. It does not matter that a valid session ID is specified, + # because the logon happens regardless of that. + # The specified session s1 is used, which is verified by defining the + # default session with invalid values. + {}, + { + '-p': 'valid', + }, + 's1', + """ +default: + host: invalid + userid: invalid + session_id: invalid + ca_verify: invalid + ca_cert_path: invalid +s1: + host: valid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", + 0, None, + True + ), + ( + "Logon data from logon opts preceding session file", + # A new session is created from the logon options, using the valid + # password. It does not matter that a valid session ID is specified, + # because the logon happens regardless of that. The logon options take + # precedence over the session file, which is verified by specifying an + # invalid host there. + {}, { '-h': 'valid', '-u': 'valid', - '-p': 'invalid', + '-p': 'valid', '-n': 'valid', '-c': 'valid', }, - 1, "ServerAuthError: HTTP authentication failed with 403,0", + None, + """ +default: + host: invalid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", + 0, None, True ), ( - "all env vars (invalid session) and logon opts with invalid pw", - # The invalid session in the env var is attempted to be deleted on the - # HMC, which fails due to being invalid, which is ignored. - # A new session is attempted to be created on the HMC, wich fails due - # to the invalid password. + "Logon data from env vars preceding session file", + # A new session is created from the env vars, using the valid + # password. It does not matter that a valid session ID is specified, + # because the logon happens regardless of that. The env vars take + # precedence over the session file, which is verified by specifying an + # invalid host there. { 'ZHMC_HOST': 'valid', 'ZHMC_USERID': 'valid', - 'ZHMC_SESSION_ID': 'invalid', + 'ZHMC_SESSION_ID': 'valid', 'ZHMC_NO_VERIFY': 'valid', 'ZHMC_CA_CERTS': 'valid', }, { - '-h': 'valid', - '-u': 'valid', - '-p': 'invalid', - '-n': 'valid', - '-c': 'valid', + '-p': 'valid', }, - 1, "ServerAuthError: HTTP authentication failed with 403,0", + None, + """ +default: + host: invalid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", + 0, None, True ), ] @pytest.mark.parametrize( - "desc, envvars, logon_opts, exp_rc, exp_err, run", - TESTCASE_SESSION_CREATE + "desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, " + "exp_err, run", + TESTCASE_SESSION_LOGON_CREATE +) +def test_session_logon( + hmc_definition, # noqa: F811 + desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, + exp_err, run): + # pylint: disable=redefined-outer-name,unused-argument + """ + Test 'session logon' command. + """ + if run is False: + pytest.skip("Testcase disabled") + + cleanup_session_ids = [] + try: + env_session_id = prepare_environ(os.environ, envvars, hmc_definition) + cleanup_session_ids.append(env_session_id) + logon_args = prepare_logon_args(logon_opts, hmc_definition) + if session_name: + logon_args.extend(['-s', session_name]) + else: + session_name = DEFAULT_SESSION_NAME + if initial_sf_str is not None: + initial_sf_str, sf_session_id = prepare_session_content( + initial_sf_str, hmc_definition) + else: + sf_session_id = None + + pdb_ = run == 'pdb' + log = (run == 'log' or TESTLOG) + + zhmc_args = logon_args + ['session', 'logon'] + + # The code to be tested + rc, stdout, stderr, result_sf_str = run_zhmc( + zhmc_args, pdb_=pdb_, log=log, initial_sf_str=initial_sf_str) + + if log: + print("Debug: test case log begin ------------------") + print(stderr) + print("Debug: test case log end --------------------") + + assert_session_logon( + rc, stdout, stderr, env_session_id, sf_session_id, session_name, + result_sf_str, hmc_definition, exp_rc, exp_err, pdb_) + + finally: + for session_id in cleanup_session_ids: + delete_hmc_session(hmc_definition, session_id) + if run == 'sleep': + time.sleep(60) + + +@pytest.mark.parametrize( + "desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, " + "exp_err, run", + TESTCASE_SESSION_LOGON_CREATE ) def test_session_create( - hmc_definition, desc, envvars, logon_opts, exp_rc, # noqa: F811 + hmc_definition, # noqa: F811 + desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, exp_err, run): # pylint: disable=redefined-outer-name,unused-argument """ @@ -544,6 +916,15 @@ def test_session_create( env_session_id = prepare_environ(os.environ, envvars, hmc_definition) cleanup_session_ids.append(env_session_id) logon_args = prepare_logon_args(logon_opts, hmc_definition) + if session_name: + logon_args.extend(['-s', session_name]) + else: + session_name = DEFAULT_SESSION_NAME + if initial_sf_str is not None: + initial_sf_str, sf_session_id = prepare_session_content( + initial_sf_str, hmc_definition) + else: + sf_session_id = None pdb_ = run == 'pdb' log = (run == 'log' or TESTLOG) @@ -551,26 +932,23 @@ def test_session_create( zhmc_args = logon_args + ['session', 'create'] # The code to be tested - rc, stdout, stderr = run_zhmc(zhmc_args, pdb_=pdb_, log=log) + rc, stdout, stderr, _ = run_zhmc( + zhmc_args, pdb_=pdb_, log=log, initial_sf_str=initial_sf_str) if log: print("Debug: test case log begin ------------------") print(stderr) print("Debug: test case log end --------------------") - if not pdb_: + if not pdb_ and rc == 0: export_vars = get_session_create_exports(stdout) new_session_id = export_vars.get('ZHMC_SESSION_ID', None) if new_session_id: cleanup_session_ids.append(new_session_id) - assert_session_create(rc, stdout, stderr, hmc_definition, - exp_rc, exp_err, pdb_) - - # If a valid session ID was provided to the command in env vars, - # verify that that session was deleted on the HMC - if env_session_id and rc == 0: - assert not is_valid_hmc_session(hmc_definition, env_session_id) + assert_session_create( + rc, stdout, stderr, env_session_id, sf_session_id, + hmc_definition, exp_rc, exp_err, pdb_) finally: for session_id in cleanup_session_ids: @@ -579,9 +957,9 @@ def test_session_create( time.sleep(60) -TESTCASE_SESSION_DELETE = [ +TESTCASE_SESSION_LOGOFF = [ - # Each item is a testcase for test_session_delete(), with the following + # Each item is a testcase for test_session_logoff(), with the following # items: # # * desc (str): Testcase description @@ -595,6 +973,11 @@ def test_session_create( # - omitted - option is not provided # - 'valid' - option is provided with valid value from HMC definition # - 'invalid' - option is provided with some invalid value + # * session_name (str): Session name in HMC session file to be used, used + # to specify the '-s' option. None means the option is not specified, + # using the default session name. + # * initial_sf_str (str): Initial content of HMC session file to be + # created for the zhmc run. If None, an empty HMC session file is created. # * exp_rc (int): Expected command exit code. # * exp_err (str): Pattern for expected error message, or None for success. # * run: Testcase run control, as follows: @@ -603,68 +986,82 @@ def test_session_create( # - 'pdb' - debug the testcase (wake up in pdb before command function) # - 'log' - enable HMC logging and display the log records # - 'sleep' - sleep for 60 sec after the testcase (used to circumvent - # temporary disablemnt of logong due to too many logons). + # temporary disablement of logon due to too many logons). ( - "no env vars and valid logon opts", - # Since there is no session ID in the env vars, no session will be - # deleted on the HMC. The credentials in the options are ignored. + "Empty session file, no env vars and no logon opts", + # Fails because the default session is not found in the session file. {}, - { - '-h': 'valid', - '-u': 'valid', - '-p': 'valid', - '-n': 'valid', - '-c': 'valid', - }, - 0, None, + {}, + None, + None, + 1, "Session not found .*: default", True ), ( - "no env vars and valid logon opts without -c", - # Since there is no session ID in the env vars, no session will be - # deleted on the HMC. The credentials in the options are ignored. - # Because -n is specified, it does not matter that -c is omitted. + "Logon opts not allowed (with empty session file)", + # Rejected because 'session logoff' intends to delete the session + # defined in the session file. {}, { '-h': 'valid', '-u': 'valid', '-p': 'valid', '-n': 'valid', + '-c': 'valid', }, - 0, None, + None, + None, + 1, " Invalid logon source .*: options", True ), ( - "no env vars and logon opts with invalid pw", - # Since there is no session ID in the env vars, no session will be - # deleted on the HMC. The credentials in the options are ignored. + "Logon opts not allowed (with default session file)", + # Rejected because 'session logoff' intends to delete the session + # defined in the session file. The presence of the default session in + # the session file does not matter for this. {}, { '-h': 'valid', '-u': 'valid', - '-p': 'invalid', + '-p': 'valid', '-n': 'valid', '-c': 'valid', }, - 0, None, + None, + """ +default: + host: valid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", + 1, " Invalid logon source .*: options", True ), ( - "just session_id env var (valid session) and no logon opts", - # A (valid) session ID is in the env vars, but for creating a Session - # oject, an HMC host is also needed. + "Env vars not allowed (with empty session file)", + # Rejected because 'session logoff' intends to delete the session + # defined in the session file. { + 'ZHMC_HOST': 'valid', + 'ZHMC_USERID': 'valid', 'ZHMC_SESSION_ID': 'valid', + 'ZHMC_NO_VERIFY': 'valid', + 'ZHMC_CA_CERTS': 'valid', }, {}, - 1, 'No HMC host provided', + None, + None, + 1, " Invalid logon source .*: environment", True ), ( - "all env vars (valid session) and no logon opts", - # The valid session ID in the env vars is used to successfully delete - # the session on the HMC. + "Env vars not allowed (with default session file)", + # Rejected because 'session logoff' intends to delete the session + # defined in the session file. The presence of the default session in + # the session file does not matter for this. { 'ZHMC_HOST': 'valid', 'ZHMC_USERID': 'valid', @@ -673,35 +1070,183 @@ def test_session_create( 'ZHMC_CA_CERTS': 'valid', }, {}, + None, + """ +default: + host: valid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", + 1, " Invalid logon source .*: environment", + True + ), + ( + "Logon data from session file with valid session ID", + # The valid session from the session file is deleted on the HMC. + {}, + {}, + None, + """ +default: + host: valid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", 0, None, True ), ( - "all env vars (invalid session) and no logon opts", - # The invalid session in the env var is attempted to be deleted on the - # HMC, which fails due to being invalid, which is ignored. - { - 'ZHMC_HOST': 'valid', - 'ZHMC_USERID': 'valid', - 'ZHMC_SESSION_ID': 'invalid', - 'ZHMC_NO_VERIFY': 'valid', - 'ZHMC_CA_CERTS': 'valid', - }, + "Logon data from session file with invalid session ID", + # The invalid session from the session file is attempted to be deleted, + # which fails because it is invalid. This failure is ignored. {}, + {}, + None, + """ +default: + host: valid + userid: valid + session_id: invalid + ca_verify: valid + ca_cert_path: valid +""", 0, None, True ), ( - "all env vars (valid session) and valid logon opts", - # The valid session ID in the env vars is used to successfully delete - # the session on the HMC. The credentials in the options are ignored. - { - 'ZHMC_HOST': 'valid', - 'ZHMC_USERID': 'valid', - 'ZHMC_SESSION_ID': 'valid', - 'ZHMC_NO_VERIFY': 'valid', - 'ZHMC_CA_CERTS': 'valid', - }, + "Logon data from session file with session name", + # The valid session s1 from the session file is deleted. + {}, + {}, + 's1', + """ +default: + host: invalid + userid: invalid + session_id: invalid + ca_verify: invalid + ca_cert_path: invalid +s1: + host: valid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", + 0, None, + True + ), +] + + +@pytest.mark.parametrize( + "desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, " + "exp_err, run", + TESTCASE_SESSION_LOGOFF +) +def test_session_logoff( + hmc_definition, # noqa: F811 + desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, + exp_err, run): + # pylint: disable=redefined-outer-name,unused-argument + """ + Test 'session logoff' command. + """ + if run is False: + pytest.skip("Testcase disabled") + + cleanup_session_ids = [] + try: + env_session_id = prepare_environ(os.environ, envvars, hmc_definition) + cleanup_session_ids.append(env_session_id) + logon_args = prepare_logon_args(logon_opts, hmc_definition) + if session_name: + logon_args.extend(['-s', session_name]) + else: + session_name = DEFAULT_SESSION_NAME + if initial_sf_str is not None: + initial_sf_str, sf_session_id = prepare_session_content( + initial_sf_str, hmc_definition) + else: + sf_session_id = None + + pdb_ = run == 'pdb' + log = (run == 'log' or TESTLOG) + + zhmc_args = logon_args + ['session', 'logoff'] + + # The code to be tested + rc, stdout, stderr, result_sf_str = run_zhmc( + zhmc_args, pdb_=pdb_, log=log, initial_sf_str=initial_sf_str) + + if log: + print("Debug: test case log begin ------------------") + print(stderr) + print("Debug: test case log end --------------------") + + assert_session_logoff( + rc, stdout, stderr, logon_opts, envvars, env_session_id, + sf_session_id, session_name, result_sf_str, hmc_definition, + exp_rc, exp_err, pdb_) + + finally: + for session_id in cleanup_session_ids: + delete_hmc_session(hmc_definition, session_id) + if run == 'sleep': + time.sleep(60) + + +TESTCASE_SESSION_DELETE = [ + + # Each item is a testcase for test_session_delete(), with the following + # items: + # + # * desc (str): Testcase description + # * envvars (dict): ZHMC env vars to be set before running command. Key is + # the var name; value indicates how to provide the var value: + # - omitted - var is not provided + # - 'valid' - var is provided with valid value from HMC definition + # - 'invalid' - var is provided with some invalid value + # * logon_opts (str): zhmc logon options to be provided in the command. + # Key is the option name; value indicates how to provide the option value: + # - omitted - option is not provided + # - 'valid' - option is provided with valid value from HMC definition + # - 'invalid' - option is provided with some invalid value + # * session_name (str): Session name in HMC session file to be used, used + # to specify the '-s' option. None means the option is not specified, + # using the default session name. + # * initial_sf_str (str): Initial content of HMC session file to be + # created for the zhmc run. If None, an empty HMC session file is created. + # * exp_rc (int): Expected command exit code. + # * exp_err (str): Pattern for expected error message, or None for success. + # * run: Testcase run control, as follows: + # - True - run the testcase + # - False - skip the testcase + # - 'pdb' - debug the testcase (wake up in pdb before command function) + # - 'log' - enable HMC logging and display the log records + # - 'sleep' - sleep for 60 sec after the testcase (used to circumvent + # temporary disablement of logon due to too many logons). + + ( + "No env vars and no logon opts", + # Since there is no session ID in the env vars, no session will be + # deleted on the HMC. The unset commands are still displayed. + {}, + {}, + None, + None, + 0, None, + True + ), + ( + "Logon opts not allowed", + # Rejected because 'session delete' intends to delete the session + # defined in the env vars. + {}, { '-h': 'valid', '-u': 'valid', @@ -709,35 +1254,63 @@ def test_session_create( '-n': 'valid', '-c': 'valid', }, - 0, None, + None, + None, + 1, "Invalid logon source .*: options", True ), ( - "all env vars (invalid session) and valid logon opts", - # The invalid session in the env var is attempted to be deleted on the - # HMC, which fails due to being invalid, which is ignored. The - # credentials in the options are ignored. + "Session name option not allowed", + # Rejected because 'session delete' intends to delete the session + # defined in the env vars. + {}, + {}, + 'default', + """ +default: + host: valid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", + 1, "Invalid logon source .*: session file", + True + ), + ( + "Just session ID env var with a valid session", + # Because ZHMC_HOST is not set, this is not recognized as logon data + # from env vars, so the logon data defaults to the HMC session file, + # where no session is defined. As a result, no session is deleted on + # the HMC. The unset commands are still displayed. { - 'ZHMC_HOST': 'valid', - 'ZHMC_USERID': 'valid', - 'ZHMC_SESSION_ID': 'invalid', - 'ZHMC_NO_VERIFY': 'valid', - 'ZHMC_CA_CERTS': 'valid', + 'ZHMC_SESSION_ID': 'valid', }, + {}, + None, + None, + 0, None, + True + ), + ( + "Just session ID env var with an invalid session", + # Because ZHMC_HOST is not set, this is not recognized as logon data + # from env vars, so the logon data defaults to the HMC session file, + # where no session is defined. As a result, no session is deleted on + # the HMC. The unset commands are still displayed. { - '-h': 'valid', - '-u': 'valid', - '-p': 'valid', - '-n': 'valid', - '-c': 'valid', + 'ZHMC_SESSION_ID': 'invalid', }, + {}, + None, + None, 0, None, True ), ( - "all env vars (valid session) and logon opts with invalid pw", - # The valid session ID in the env vars is used to successfully delete - # the session on the HMC. The credentials in the options are ignored. + "All env vars with a valid session", + # The session is logged off on the HMC and the unset commands are + # displayed. { 'ZHMC_HOST': 'valid', 'ZHMC_USERID': 'valid', @@ -745,21 +1318,16 @@ def test_session_create( 'ZHMC_NO_VERIFY': 'valid', 'ZHMC_CA_CERTS': 'valid', }, - { - '-h': 'valid', - '-u': 'valid', - '-p': 'invalid', - '-n': 'valid', - '-c': 'valid', - }, + {}, + None, + None, 0, None, True ), ( - "all env vars (invalid session) and logon opts with invalid pw", + "All env vars with an invalid session", # The invalid session in the env var is attempted to be deleted on the - # HMC, which fails due to being invalid, which is ignored. The - # credentials in the options are ignored. + # HMC, which fails due to being invalid, which is ignored. { 'ZHMC_HOST': 'valid', 'ZHMC_USERID': 'valid', @@ -767,13 +1335,33 @@ def test_session_create( 'ZHMC_NO_VERIFY': 'valid', 'ZHMC_CA_CERTS': 'valid', }, + {}, + None, + None, + 0, None, + True + ), + ( + "Session file is ignored", + # An existing session file is ignored as long as it is not the logon + # source. { - '-h': 'valid', - '-u': 'valid', - '-p': 'invalid', - '-n': 'valid', - '-c': 'valid', + 'ZHMC_HOST': 'valid', + 'ZHMC_USERID': 'valid', + 'ZHMC_SESSION_ID': 'valid', + 'ZHMC_NO_VERIFY': 'valid', + 'ZHMC_CA_CERTS': 'valid', }, + {}, + None, + """ +default: + host: valid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", 0, None, True ), @@ -781,11 +1369,13 @@ def test_session_create( @pytest.mark.parametrize( - "desc, envvars, logon_opts, exp_rc, exp_err, run", + "desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, " + "exp_err, run", TESTCASE_SESSION_DELETE ) def test_session_delete( - hmc_definition, desc, envvars, logon_opts, exp_rc, # noqa: F811 + hmc_definition, # noqa: F811 + desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, exp_err, run): # pylint: disable=redefined-outer-name,unused-argument """ @@ -799,6 +1389,15 @@ def test_session_delete( env_session_id = prepare_environ(os.environ, envvars, hmc_definition) cleanup_session_ids.append(env_session_id) logon_args = prepare_logon_args(logon_opts, hmc_definition) + if session_name: + logon_args.extend(['-s', session_name]) + else: + session_name = DEFAULT_SESSION_NAME + if initial_sf_str is not None: + initial_sf_str, sf_session_id = prepare_session_content( + initial_sf_str, hmc_definition) + else: + sf_session_id = None pdb_ = run == 'pdb' log = (run == 'log' or TESTLOG) @@ -806,20 +1405,18 @@ def test_session_delete( zhmc_args = logon_args + ['session', 'delete'] # The code to be tested - rc, stdout, stderr = run_zhmc(zhmc_args, pdb_=pdb_, log=log) + rc, stdout, stderr, result_sf_str = run_zhmc( + zhmc_args, pdb_=pdb_, log=log, initial_sf_str=initial_sf_str) if log: print("Debug: test case log begin ------------------") print(stderr) print("Debug: test case log end --------------------") - assert_session_delete(rc, stdout, stderr, hmc_definition, - exp_rc, exp_err, pdb_) - - # If a valid session ID was provided to the command in env vars, - # verify that that session was deleted on the HMC - if env_session_id and rc == 0: - assert not is_valid_hmc_session(hmc_definition, env_session_id) + assert_session_delete( + rc, stdout, stderr, logon_opts, envvars, env_session_id, + sf_session_id, session_name, result_sf_str, hmc_definition, + exp_rc, exp_err, pdb_) finally: for session_id in cleanup_session_ids: @@ -844,6 +1441,11 @@ def test_session_delete( # - omitted - option is not provided # - 'valid' - option is provided with valid value from HMC definition # - 'invalid' - option is provided with some invalid value + # * session_name (str): Session name in HMC session file to be used, used + # to specify the '-s' option. None means the option is not specified, + # using the default session name. + # * initial_sf_str (str): Initial content of HMC session file to be + # created for the zhmc run. If None, an empty HMC session file is created. # * exp_rc (int): Expected command exit code. # * exp_err (str): Pattern for expected error message, or None for success. # * run: Testcase run control, as follows: @@ -852,10 +1454,10 @@ def test_session_delete( # - 'pdb' - debug the testcase (wake up in pdb before command function) # - 'log' - enable HMC logging and display the log records # - 'sleep' - sleep for 60 sec after the testcase (used to circumvent - # temporary disablemnt of logong due to too many logons). + # temporary disablement of logon due to too many logons). ( - "no env vars and valid logon opts", + "Logon data from opts", # Since there is no session ID in the env vars, a new session is created # on the HMC, using the valid password, and again deleted on the HMC # after the command. @@ -867,11 +1469,13 @@ def test_session_delete( '-n': 'valid', '-c': 'valid', }, + None, + None, 0, None, True ), ( - "no env vars and valid logon opts without -c", + "Logon data from logon opts without -c", # Since there is no session ID in the env vars, a new session is created # on the HMC, using the valid password, and again deleted on the HMC # after the command. @@ -883,11 +1487,26 @@ def test_session_delete( '-p': 'valid', '-n': 'valid', }, + None, + None, 0, None, True ), ( - "no env vars and logon opts with invalid pw", + "Logon data from opts with only -h", + # If logon opts are used (detected by presence of -h), a userid is + # required. + {}, + { + '-h': 'valid', + }, + None, + None, + 1, "Required option not specified: --userid", + True + ), + ( + "Logon data from opts with invalid password", # Since there is no session ID in the env vars, a new session is created # on the HMC, using the invalid password, which fails. {}, @@ -898,22 +1517,26 @@ def test_session_delete( '-n': 'valid', '-c': 'valid', }, + None, + None, 1, "ServerAuthError: HTTP authentication failed with 403,0", True ), ( - "just session_id env var (valid session) and no logon opts", + "Just session ID env var with valid session", # A (valid) session ID is in the env vars, but for creating a Session - # oject, an HMC host is also needed. + # object, an HMC host is also needed. { 'ZHMC_SESSION_ID': 'valid', }, {}, - 1, 'No HMC host provided', + None, + None, + 1, 'No HMC host or session in HMC session file provided', True ), ( - "all env vars (valid session) and no logon opts", + "Logon data from all env vars with valid session", # The valid session ID in the env vars is used to execute the command # on the HMC. The session is not deleted after the command. { @@ -924,17 +1547,18 @@ def test_session_delete( 'ZHMC_CA_CERTS': 'valid', }, {}, + None, + None, 0, None, True ), ( - "all env vars (invalid session) and no logon opts", + "Logon data from all env vars with invalid session", # The invalid session ID in the env vars is attempted to be used to # execute the command, which fails, which causes a session renewal. - # Because no password is created, it is prompted for. - # A new session is created on the HMC and then the command is - # successfully executed. The session is deleted after the command - # (because it cannot be stored for reuse anyway). + # A new session is created on the HMC using the valid password, and + # then the command is successfully executed. The session is deleted + # after the command. { 'ZHMC_HOST': 'valid', 'ZHMC_USERID': 'valid', @@ -942,19 +1566,22 @@ def test_session_delete( 'ZHMC_NO_VERIFY': 'valid', 'ZHMC_CA_CERTS': 'valid', }, - {}, + { + '-p': 'valid' + }, + None, + None, 0, None, - # Disabled this test case because the command prompts for the password. - # TODO: Add support for providing the password to the prompt - False + True ), ( - "all env vars (valid session) and valid logon opts", - # The valid session ID in the env vars is used to execute the command - # on the HMC. The session is not deleted after the command. The - # credentials in the options are not used. + "Logon data from logon opts preceding env vars", + # The logon options take precedence and the env vars are ignored, + # which is verified by specifying an invalid host. + # The command is executed with a new temporary session created from + # the logon options. { - 'ZHMC_HOST': 'valid', + 'ZHMC_HOST': 'invalid', 'ZHMC_USERID': 'valid', 'ZHMC_SESSION_ID': 'valid', 'ZHMC_NO_VERIFY': 'valid', @@ -967,24 +1594,18 @@ def test_session_delete( '-n': 'valid', '-c': 'valid', }, + None, + None, 0, None, True ), ( - "all env vars (invalid session) and valid logon opts", - # The invalid session ID in the env vars is attempted to be used to - # execute the command, which fails, which causes a session renewal. - # A new session is created on the HMC using the valid password, - # and then the command is successfully executed. The session is - # deleted after the command (because it cannot be stored for reuse - # anyway). - { - 'ZHMC_HOST': 'valid', - 'ZHMC_USERID': 'valid', - 'ZHMC_SESSION_ID': 'invalid', - 'ZHMC_NO_VERIFY': 'valid', - 'ZHMC_CA_CERTS': 'valid', - }, + "Logon data from logon opts preceding session file", + # The logon options take precedence and the session file is ignored, + # which is verified by specifying an invalid host. + # The command is executed with a new temporary session created from + # the logon options. + {}, { '-h': 'valid', '-u': 'valid', @@ -992,15 +1613,23 @@ def test_session_delete( '-n': 'valid', '-c': 'valid', }, + None, + """ +default: + host: invalid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", 0, None, True ), ( - "all env vars (valid session) and logon opts with invalid pw", - # The valid session ID in the env vars is used to execute the command - # on the HMC. The session is not deleted after the command. The - # credentials in the options are not used, so it does not matter - # that the password is invalid. + "Logon data from env vars preceding session file", + # The env vars take precedence and the session file is ignored, + # which is verified by specifying an invalid host. + # The command is executed using the session from the env vars. { 'ZHMC_HOST': 'valid', 'ZHMC_USERID': 'valid', @@ -1008,48 +1637,30 @@ def test_session_delete( 'ZHMC_NO_VERIFY': 'valid', 'ZHMC_CA_CERTS': 'valid', }, - { - '-h': 'valid', - '-u': 'valid', - '-p': 'invalid', - '-n': 'valid', - '-c': 'valid', - }, + {}, + None, + """ +default: + host: invalid + userid: valid + session_id: valid + ca_verify: valid + ca_cert_path: valid +""", 0, None, True ), - ( - "all env vars (invalid session) and logon opts with invalid pw", - # The invalid session ID in the env vars is attempted to be used to - # execute the command, which fails, which causes a session renewal. - # A new session is attempted to be created on the HMC using the invalid - # password, which fails. - { - 'ZHMC_HOST': 'valid', - 'ZHMC_USERID': 'valid', - 'ZHMC_SESSION_ID': 'invalid', - 'ZHMC_NO_VERIFY': 'valid', - 'ZHMC_CA_CERTS': 'valid', - }, - { - '-h': 'valid', - '-u': 'valid', - '-p': 'invalid', - '-n': 'valid', - '-c': 'valid', - }, - 1, "ServerAuthError: HTTP authentication failed with 403,0", - True - ), ] @pytest.mark.parametrize( - "desc, envvars, logon_opts, exp_rc, exp_err, run", + "desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, " + "exp_err, run", TESTCASE_SESSION_COMMAND ) def test_session_command( - hmc_definition, desc, envvars, logon_opts, exp_rc, # noqa: F811 + hmc_definition, # noqa: F811 + desc, envvars, logon_opts, session_name, initial_sf_str, exp_rc, exp_err, run): # pylint: disable=redefined-outer-name,unused-argument """ @@ -1063,6 +1674,15 @@ def test_session_command( env_session_id = prepare_environ(os.environ, envvars, hmc_definition) cleanup_session_ids.append(env_session_id) logon_args = prepare_logon_args(logon_opts, hmc_definition) + if session_name: + logon_args.extend(['-s', session_name]) + else: + session_name = DEFAULT_SESSION_NAME + if initial_sf_str is not None: + initial_sf_str, sf_session_id = prepare_session_content( + initial_sf_str, hmc_definition) + else: + sf_session_id = None pdb_ = run == 'pdb' log = (run == 'log' or TESTLOG) @@ -1071,20 +1691,17 @@ def test_session_command( # The code to be tested # pylint: disable=unused-variable - rc, stdout, stderr = run_zhmc(zhmc_args, pdb_=pdb_, log=log) + rc, stdout, stderr, result_sf_str = run_zhmc( + zhmc_args, pdb_=pdb_, log=log, initial_sf_str=initial_sf_str) if log: print("Debug: test case log begin ------------------") print(stderr) print("Debug: test case log end --------------------") - assert_session_command(rc, stdout, stderr, hmc_definition, - exp_rc, exp_err, pdb_) - - # If a valid session ID was provided to the command in env vars, - # verify that that session was not deleted on the HMC - if env_session_id: - assert is_valid_hmc_session(hmc_definition, env_session_id) + assert_session_command( + rc, stdout, stderr, env_session_id, sf_session_id, hmc_definition, + exp_rc, exp_err, pdb_) finally: for session_id in cleanup_session_ids: diff --git a/tests/end2end/utils.py b/tests/end2end/utils.py index a26bd614..3fde90f6 100644 --- a/tests/end2end/utils.py +++ b/tests/end2end/utils.py @@ -25,6 +25,9 @@ import pytest import zhmcclient +from ..function.test_session_file import create_session_file, \ + delete_session_file, session_file_content + # Prefix used for names of resources that are created during tests TEST_PREFIX = 'zhmcclient_tests_end2end' @@ -142,7 +145,7 @@ def is_valid_hmc_session(hmc_definition, session_id): return True -def run_zhmc(args, env=None, pdb_=False, log=False): +def run_zhmc(args, env=None, pdb_=False, log=False, initial_sf_str=None): """ Run the zhmc command and return its exit code, stdout and stderr. @@ -166,11 +169,15 @@ def run_zhmc(args, env=None, pdb_=False, log=False): If both log and pdb_ are set, only pdb_ is performed. + initial_sf_str(str): Initial content of HMC session file to be created for + use by this zhmc run. If None, an empty HMC session file is created. + Returns: tuple (rc, stdout, stderr) as follows: - rc(int): zhmc exit code - stdout(str): stdout string, or None if debugging the zhmc command - stderr(str): stderr string, or None if debugging the zhmc command + - result_sf_str(str): Resulting content of HMC session file """ assert isinstance(args, (list, tuple)) if env is not None: @@ -191,16 +198,28 @@ def run_zhmc(args, env=None, pdb_=False, log=False): 'stderr': subprocess.PIPE, } - # pylint: disable=consider-using-with - proc = subprocess.Popen(p_args, env=env, **kwargs) + # Prepare the temporary HMC session file for this test and make sure it + # is used. + session_file = create_session_file(initial_sf_str) + os.environ['_ZHMC_TEST_SESSION_FILEPATH'] = session_file.filepath + + try: + + # pylint: disable=consider-using-with + proc = subprocess.Popen(p_args, env=env, **kwargs) + + stdout, stderr = proc.communicate() + rc = proc.returncode + if not pdb_: + stdout = stdout.decode() + stderr = stderr.decode() + + result_sf_str = session_file_content(session_file) - stdout, stderr = proc.communicate() - rc = proc.returncode - if not pdb_: - stdout = stdout.decode() - stderr = stderr.decode() + finally: + delete_session_file(session_file) - return rc, stdout, stderr + return rc, stdout, stderr, result_sf_str def _res_name(item): diff --git a/tests/function/test_info.py b/tests/function/test_info.py index 08ce922e..2eb8d4ca 100644 --- a/tests/function/test_info.py +++ b/tests/function/test_info.py @@ -70,7 +70,7 @@ def test_info_error_no_host(self): assert_rc(1, rc, stdout, stderr) assert stdout == "" assert stderr.startswith( - "Error: No HMC host provided\n"), \ + "Error: No HMC host or session in HMC session file provided\n"), \ f"stderr={stderr!r}" def test_info_error_no_conn(self): @@ -80,7 +80,7 @@ def test_info_error_no_conn(self): # Invoke the command to be tested rc, stdout, stderr = call_zhmc_child( ['info'], - {'ZHMC_HOST': 'invalid_host'} + {'ZHMC_HOST': 'invalid_host', 'ZHMC_USERID': 'user'} ) assert_rc(1, rc, stdout, stderr) @@ -205,7 +205,8 @@ def test_option_outputformat_table( """ faked_session = FakedSession( - 'fake-host', hmc_name, hmc_version, api_version) + 'fake-host', hmc_name, hmc_version, api_version, + userid='fake-user') api_version_parts = [int(vp) for vp in api_version.split('.')] exp_values = { 'hnam': hmc_name, @@ -266,7 +267,8 @@ def test_option_outputformat_json( """ faked_session = FakedSession( - 'fake-host', hmc_name, hmc_version, api_version) + 'fake-host', hmc_name, hmc_version, api_version, + userid='fake-user') args = [out_opt, 'json'] if transpose_opt is not None: @@ -325,7 +327,7 @@ def test_option_errorformat(self, err_opt, err_format, exp_stderr_patterns): # Invoke the command to be tested rc, stdout, stderr = call_zhmc_child( err_args + ['info'], - {'ZHMC_HOST': 'invalid_host'} + {'ZHMC_HOST': 'invalid_host', 'ZHMC_USERID': 'user'} ) assert_rc(1, rc, stdout, stderr) @@ -371,7 +373,8 @@ def test_option_log( """Test 'zhmc info' with global option --log""" faked_session = FakedSession( - 'fake-host', hmc_name, hmc_version, api_version) + 'fake-host', hmc_name, hmc_version, api_version, + userid='fake-user') # Invoke the command to be tested rc, stdout, stderr = call_zhmc_inline( @@ -404,7 +407,8 @@ def test_option_logdest( """Test 'zhmc info' with global option --log-dest (and --log)""" faked_session = FakedSession( - 'fake-host', hmc_name, hmc_version, api_version) + 'fake-host', hmc_name, hmc_version, api_version, + userid='fake-user') args = ['--log', 'api=debug'] logger_name = 'zhmcclient.api' # corresponds to --log option diff --git a/tests/function/test_session_file.py b/tests/function/test_session_file.py new file mode 100644 index 00000000..1fb81868 --- /dev/null +++ b/tests/function/test_session_file.py @@ -0,0 +1,956 @@ +# 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. + +""" +Function tests for _session_file.py module. +""" + +import os +import re +import tempfile +import pytest +from zhmcclient_mock import FakedSession + +from zhmccli._session_file import HMCSession, HMCSessionFile, \ + HMCSessionException, HMCSessionNotFound, HMCSessionAlreadyExists, \ + HMCSessionFileNotFound, HMCSessionFileError, HMCSessionFileFormatError, \ + BLANKED_OUT_STRING + + +def create_session_file(sf_str): + """ + Create an HMC session file with the specified content. + + The content string does not need to be valid according to the HMC session + file schema, in order to be able to provoke format errors. + The HMC session file is closed upon return. + + If the 'sf_str' parameter is None, no session file is created, and the + path name of a non-existing file is returned. + + Parameters: + sf_str (str): Content. May be None. + + Returns: + HMCSessionFile: HMC session file. + """ + home_dir = os.path.expanduser('~') + # pylint: disable=consider-using-with + file = tempfile.NamedTemporaryFile( + mode='w', encoding="utf-8", delete=False, + suffix='yaml', prefix='.test_zhmc_sessions_', dir=home_dir) + filepath = file.name + if sf_str is None: + file.close() + os.remove(filepath) + else: + file.write(sf_str) + file.close() + return HMCSessionFile(filepath) + + +def delete_session_file(session_file): + """ + Delete an HMC session file, if it exists. + + Parameters: + session_file (HMCSessionFile): HMC session file. + """ + filepath = session_file.filepath + if os.path.exists(filepath): + os.remove(filepath) + + +def session_file_content(session_file): + """ + Return the content of an HMC session file, if it exists. + + If it does not exist, return None. + + Parameters: + session_file (HMCSessionFile): HMC session file. + + Returns: + str: Content of HMC session file, or None. + """ + filepath = session_file.filepath + if os.path.exists(filepath): + with open(filepath, "r", encoding="utf-8") as fp: + sf_str = fp.read() + return sf_str + return None + + +def assert_session_equal(act_session, exp_session, session_name): + """ + Assert that two HMCSession objects are equal. + """ + for attr in ("host", "userid", "session_id", "ca_verify", "ca_cert_path"): + assert isinstance(act_session, HMCSession) + assert isinstance(exp_session, HMCSession) + act_value = getattr(act_session, attr) + exp_value = getattr(exp_session, attr) + assert act_value == exp_value, ( + f"Unexpected value of HMCSession {session_name!r} attribute " + f"{attr!r}:\n" + f" Actual value: {act_value!r}\n" + f" Expected value: {exp_value!r}") + + +def assert_session_file_content(session_file, exp_sf_str): + """ + Assert that the HMC session file has the expected content. + + Because loading an HMC session file changes a non-existing file to exist + with content '{}', this needs to be considered (they are considered + equal). + """ + act_sf_str = session_file_content(session_file) + if exp_sf_str is None and act_sf_str == "{}": + return + assert act_sf_str == exp_sf_str, ( + f"HMC session file {session_file.filepath!r} has unexpected content:\n" + f" Actual content:\n{act_sf_str}\n" + f" Expected content:\n{exp_sf_str}\n") + + +@pytest.mark.parametrize( + "exc_type", + [ + HMCSessionNotFound, + HMCSessionAlreadyExists, + HMCSessionFileNotFound, + HMCSessionFileError, + HMCSessionFileFormatError + ] +) +def test_session_file_exception(exc_type): + """ + Test session file exception. + """ + message = "Message" + + # The code to be tested + exc = exc_type(message) + + assert isinstance(exc, Exception) + assert isinstance(exc, HMCSessionException) + assert str(exc) == message + assert exc.args[0] == message + + +def test_session_repr(): + """ + Test HMCSession.__repr__(). + """ + host = "my_host" + userid = "my_userid" + session_id = "my_session_id" + ca_verify = False + ca_cert_path = None + + session = HMCSession( + host=host, + userid=userid, + session_id=session_id, + ca_verify=ca_verify, + ca_cert_path=ca_cert_path) + + # The code to be tested + repr_str = repr(session) + + exp_repr_str = ( + "HMCSession(" + f"host={host!r}, " + f"userid={userid!r}, " + f"session_id={BLANKED_OUT_STRING}, " + f"ca_verify={ca_verify!r}, " + f"ca_cert_path={ca_cert_path!r})") + + assert repr_str == exp_repr_str + + +def test_session_as_dict(): + """ + Test HMCSession.as_dict(). + """ + host = "my_host" + userid = "my_userid" + session_id = "my_session_id" + ca_verify = False + ca_cert_path = None + + session = HMCSession( + host=host, + userid=userid, + session_id=session_id, + ca_verify=ca_verify, + ca_cert_path=ca_cert_path) + + # The code to be tested + session_dict = session.as_dict() + + assert isinstance(session_dict, dict) + assert len(session_dict) == 5 + assert session_dict['host'] == host + assert session_dict['userid'] == userid + assert session_dict['session_id'] == session_id + assert session_dict['ca_verify'] == ca_verify + assert session_dict['ca_cert_path'] == ca_cert_path + + +TESTCASES_SESSION_FROM_ZHMCCLIENT = [ + + # Each item is a testcase for testing HMCSession.from_zhmcclient_session(), + # with the following items: + # + # * verify_cert (bool/str): Input parameter for zhmcclient session. + # * exp_ca_verify (bool): Expected property from function result. + # * exp_ca_cert_path (str): Expected property from function result. + + ( + False, + False, + None + ), + ( + True, + True, + None + ), + ( + 'dir/file.pem', + True, + 'dir/file.pem' + ), +] + + +@pytest.mark.parametrize( + "verify_cert, exp_ca_verify, exp_ca_cert_path", + TESTCASES_SESSION_FROM_ZHMCCLIENT +) +def test_session_from_zhmcclient(verify_cert, exp_ca_verify, exp_ca_cert_path): + """ + Test HMCSession.from_zhmcclient_session(). + """ + host = "my_host" + userid = "my_userid" + session_id = "my_session_id" + + faked_session = FakedSession(host, 'hmc1', '2.16', '4.10', userid=userid) + faked_session._session_id = session_id # pylint: disable=protected-access + faked_session._verify_cert = verify_cert # pylint: disable=protected-access + + # The code to be tested + hmc_session = HMCSession.from_zhmcclient_session(faked_session) + + assert hmc_session.host == host + assert hmc_session.userid == userid + assert hmc_session.session_id == session_id + assert hmc_session.ca_verify == exp_ca_verify + assert hmc_session.ca_cert_path == exp_ca_cert_path + + +TESTCASES_SESSION_FILE_REPR = [ + + # Each item is a testcase for testing HMCSessionFile.__repr__(), + # with the following items: + # + # * sf_str (str/None): Content for HMC session file, or None for an empty + # HMC session file. + # * exp_repr_data (str): Expected data shown in repr string. + + ( + None, + "{}" + ), + ( + """ +default: + host: my_host + userid: my_userid + session_id: my_session_id + ca_verify: false + ca_cert_path: null +""", + "{'default': {...}}" + ), +] + + +@pytest.mark.parametrize( + "sf_str, exp_repr_data", + TESTCASES_SESSION_FILE_REPR +) +def test_session_file_repr(sf_str, exp_repr_data): + """ + Test HMCSessionFile.__repr__(). + """ + session_file = create_session_file(sf_str) + filepath = session_file.filepath + + try: + + # The code to be tested + repr_str = repr(session_file) + + # pylint: disable=protected-access + exp_repr_str = ( + "HMCSessionFile(" + f"filepath={filepath!r}, " + f"data={exp_repr_data})") + + assert repr_str == exp_repr_str + + finally: + delete_session_file(session_file) + + +TESTCASES_SESSION_FILE_LIST = [ + + # Each item is a testcase for testing HMCSessionFile.list(), with the + # following items: + # + # * desc (str): Testcase description. + # * initial_content (str): Content of initial HMC session file that is + # created before calling the function to be tested. None means there is + # no initial HMC session file. + # * exp_result (dict): Expected result from the function to be tested. + # Key(str): Session name, Value(HMCSession): HMC session. + # * exp_exc_type (exc): Expected exception class. None for no exception. + # * exp_exc_msg (str): Expected pattern for exception message. None for + # no exception. + + ( + "no session file", + None, + {}, + None, None + ), + ( + "invalid YAML format", + "{\n", + None, + HMCSessionFileFormatError, + "Invalid YAML syntax in HMC session file" + ), + ( + "invalid schema", + "42\n", + None, + HMCSessionFileFormatError, + "Invalid data format in HMC session file" + ), + ( + "empty session file", + "{}\n", + {}, + None, None + ), + ( + "session file with one valid session", + """ +default: + host: my_host + userid: my_userid + session_id: my_session_id + ca_verify: false + ca_cert_path: null + """, + { + 'default': HMCSession( + host="my_host", + userid="my_userid", + session_id="my_session_id", + ca_verify=False, + ca_cert_path=None) + }, + None, None + ), + ( + "session file with two valid sessions", + """ +default: + host: my_host + userid: my_userid + session_id: my_session_id + ca_verify: false + ca_cert_path: null +s2: + host: my_host2 + userid: my_userid2 + session_id: my_session_id2 + ca_verify: false + ca_cert_path: null + """, + { + 'default': HMCSession( + host="my_host", + userid="my_userid", + session_id="my_session_id", + ca_verify=False, + ca_cert_path=None), + 's2': HMCSession( + host="my_host2", + userid="my_userid2", + session_id="my_session_id2", + ca_verify=False, + ca_cert_path=None) + }, + None, None + ), + ( + "session file with one valid session with missing properties", + """ +default: + host: my_host + userid: my_userid + session_id: my_session_id + ca_verify: false + """, + None, + HMCSessionFileFormatError, + "Invalid data format in HMC session file" + ), +] + + +@pytest.mark.parametrize( + "desc, initial_content, exp_result, exp_exc_type, exp_exc_msg", + TESTCASES_SESSION_FILE_LIST +) +def test_session_file_list( + desc, initial_content, exp_result, exp_exc_type, exp_exc_msg): + # pylint: disable=unused-argument + """ + Test HMCSessionFile.list(). + """ + session_file = create_session_file(initial_content) + + try: + if exp_exc_type: + with pytest.raises(exp_exc_type) as exc_info: + + # The code to be tested + session_file.list() + + 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 code to be tested + result = session_file.list() + + assert isinstance(result, dict) + assert set(result.keys()) == set(exp_result.keys()) + for name, act_session in result.items(): + assert name in exp_result + exp_session = exp_result[name] + assert_session_equal(act_session, exp_session, name) + + assert_session_file_content(session_file, initial_content) + + finally: + delete_session_file(session_file) + + +TESTCASES_SESSION_FILE_GET = [ + + # Each item is a testcase for testing HMCSessionFile.get(), with the + # following items: + # + # * desc (str): Testcase description. + # * initial_content (str): Content of initial HMC session file that is + # created before calling the function to be tested. None means there is + # no initial HMC session file. + # * session_name (str): Session name to be retrieved. + # * exp_session (HMCSession): Expected HMC session, or None for exception. + # * exp_exc_type (exc): Expected exception class. None for no exception. + # * exp_exc_msg (str): Expected pattern for exception message. None for + # no exception. + + ( + "no session file", + None, + "foo", + None, + HMCSessionNotFound, + "Session not found in HMC session file" + ), + ( + "invalid YAML format", + "{\n", + "foo", + None, + HMCSessionFileFormatError, + "Invalid YAML syntax in HMC session file" + ), + ( + "invalid schema", + "42\n", + "foo", + None, + HMCSessionFileFormatError, + "Invalid data format in HMC session file" + ), + ( + "empty session file", + "{}\n", + "foo", + None, + HMCSessionNotFound, + "Session not found in HMC session file" + ), + ( + "session file with two valid sessions, one of those requested", + """ +default: + host: my_host + userid: my_userid + session_id: my_session_id + ca_verify: false + ca_cert_path: null +s2: + host: my_host2 + userid: my_userid2 + session_id: my_session_id2 + ca_verify: false + ca_cert_path: null + """, + "s2", + HMCSession( + host="my_host2", + userid="my_userid2", + session_id="my_session_id2", + ca_verify=False, + ca_cert_path=None), + None, None + ), + ( + "session file with two valid sessions, none of those requested", + """ +default: + host: my_host + userid: my_userid + session_id: my_session_id + ca_verify: false + ca_cert_path: null +s2: + host: my_host2 + userid: my_userid2 + session_id: my_session_id2 + ca_verify: false + ca_cert_path: null + """, + "foo", + None, + HMCSessionNotFound, + "Session not found in HMC session file" + ), +] + + +@pytest.mark.parametrize( + "desc, initial_content, session_name, exp_session, exp_exc_type, " + "exp_exc_msg", + TESTCASES_SESSION_FILE_GET +) +def test_session_file_get( + desc, initial_content, session_name, exp_session, exp_exc_type, + exp_exc_msg): + # pylint: disable=unused-argument + """ + Test HMCSessionFile.get(). + """ + session_file = create_session_file(initial_content) + + try: + if exp_exc_type: + with pytest.raises(exp_exc_type) as exc_info: + + # The code to be tested + session_file.get(session_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 code to be tested + session = session_file.get(session_name) + + assert isinstance(session, HMCSession) + assert_session_equal(session, exp_session, session_name) + + assert_session_file_content(session_file, initial_content) + + finally: + delete_session_file(session_file) + + +TESTCASES_SESSION_FILE_ADD = [ + + # Each item is a testcase for testing HMCSessionFile.add(), with the + # following items: + # + # * desc (str): Testcase description. + # * initial_content (str): Content of initial HMC session file that is + # created before calling the function to be tested. None means there is + # no initial HMC session file. + # * session_name (str): Session name to be added. + # * session (HMCSession): Session to be added. + # * exp_exc_type (exc): Expected exception class. None for no exception. + # * exp_exc_msg (str): Expected pattern for exception message. None for + # no exception. + + ( + "no session file", + None, + "foo", + HMCSession( + host="my_host", + userid="my_userid", + session_id="my_session_id", + ca_verify=False, + ca_cert_path=None), + None, None + ), + ( + "empty session file", + "{}\n", + "foo", + HMCSession( + host="my_host", + userid="my_userid", + session_id="my_session_id", + ca_verify=False, + ca_cert_path=None), + None, None + ), + ( + "session file with one valid session, new session does not exist", + """ +default: + host: my_host_def + userid: my_userid_def + session_id: my_session_id_def + ca_verify: false + ca_cert_path: null + """, + "foo", + HMCSession( + host="my_host", + userid="my_userid", + session_id="my_session_id", + ca_verify=False, + ca_cert_path=None), + None, None + ), + ( + "session file with one valid session, new session exists", + """ +foo: + host: my_host_def + userid: my_userid_def + session_id: my_session_id_def + ca_verify: false + ca_cert_path: null + """, + "foo", + HMCSession( + host="my_host", + userid="my_userid", + session_id="my_session_id", + ca_verify=False, + ca_cert_path=None), + HMCSessionAlreadyExists, + "Session already exists in HMC session file" + ), +] + + +@pytest.mark.parametrize( + "desc, initial_content, session_name, session, exp_exc_type, " + "exp_exc_msg", + TESTCASES_SESSION_FILE_ADD +) +def test_session_file_add( + desc, initial_content, session_name, session, exp_exc_type, + exp_exc_msg): + # pylint: disable=unused-argument + """ + Test HMCSessionFile.add(). + """ + session_file = create_session_file(initial_content) + + try: + if exp_exc_type: + with pytest.raises(exp_exc_type) as exc_info: + + # The code to be tested + session_file.add(session_name, session) + + 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) + + assert_session_file_content(session_file, initial_content) + + else: + + # The code to be tested + session_file.add(session_name, session) + + retrieved_session = session_file.get(session_name) + + assert_session_equal(session, retrieved_session, session_name) + + finally: + delete_session_file(session_file) + + +TESTCASES_SESSION_FILE_REMOVE = [ + + # Each item is a testcase for testing HMCSessionFile.remove(), with the + # following items: + # + # * desc (str): Testcase description. + # * initial_content (str): Content of initial HMC session file that is + # created before calling the function to be tested. None means there is + # no initial HMC session file. + # * session_name (str): Session name to be removed. + # * exp_exc_type (exc): Expected exception class. None for no exception. + # * exp_exc_msg (str): Expected pattern for exception message. None for + # no exception. + + ( + "no session file", + None, + "foo", + HMCSessionNotFound, + "Session not found in HMC session file" + ), + ( + "empty session file", + "{}\n", + "foo", + HMCSessionNotFound, + "Session not found in HMC session file" + ), + ( + "session file with one valid session that is session to be removed", + """ +foo: + host: my_host_def + userid: my_userid_def + session_id: my_session_id_def + ca_verify: false + ca_cert_path: null + """, + "foo", + None, None + ), + ( + "session file with one valid session that is not session to be removed", + """ +default: + host: my_host_def + userid: my_userid_def + session_id: my_session_id_def + ca_verify: false + ca_cert_path: null + """, + "foo", + HMCSessionNotFound, + "Session not found in HMC session file" + ), +] + + +@pytest.mark.parametrize( + "desc, initial_content, session_name, exp_exc_type, exp_exc_msg", + TESTCASES_SESSION_FILE_REMOVE +) +def test_session_file_remove( + desc, initial_content, session_name, exp_exc_type, exp_exc_msg): + # pylint: disable=unused-argument + """ + Test HMCSessionFile.remove(). + """ + session_file = create_session_file(initial_content) + + try: + if exp_exc_type: + with pytest.raises(exp_exc_type) as exc_info: + + # The code to be tested + session_file.remove(session_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) + + assert_session_file_content(session_file, initial_content) + + else: + + # The code to be tested + session_file.remove(session_name) + + with pytest.raises(HMCSessionNotFound): + session_file.get(session_name) + + finally: + delete_session_file(session_file) + + +TESTCASES_SESSION_FILE_UPDATE = [ + + # Each item is a testcase for testing HMCSessionFile.update(), with the + # following items: + # + # * desc (str): Testcase description. + # * initial_content (str): Content of initial HMC session file that is + # created before calling the function to be tested. None means there is + # no initial HMC session file. + # * session_name (str): Session name to be removed. + # * updates (dict): Dict with updates to the session. + # * exp_exc_type (exc): Expected exception class. None for no exception. + # * exp_exc_msg (str): Expected pattern for exception message. None for + # no exception. + + ( + "no session file", + None, + "foo", + {}, + HMCSessionNotFound, + "Session not found in HMC session file" + ), + ( + "empty session file", + "{}\n", + "foo", + {}, + HMCSessionNotFound, + "Session not found in HMC session file" + ), + ( + "update nothing", + """ +foo: + host: my_host + userid: my_userid + session_id: my_session_id + ca_verify: false + ca_cert_path: null + """, + "foo", + {}, + None, None + ), + ( + "update everything", + """ +foo: + host: my_host + userid: my_userid + session_id: my_session_id + ca_verify: false + ca_cert_path: null + """, + "foo", + { + "host": "my_host_2", + "userid": "my_userid_2", + "session_id": "my_session_id_2", + "ca_verify": True, + "ca_cert_path": "foo", + }, + None, None + ), +] + + +@pytest.mark.parametrize( + "desc, initial_content, session_name, updates, exp_exc_type, exp_exc_msg", + TESTCASES_SESSION_FILE_UPDATE +) +def test_session_file_update( + desc, initial_content, session_name, updates, exp_exc_type, + exp_exc_msg): + # pylint: disable=unused-argument + """ + Test HMCSessionFile.update(). + """ + session_file = create_session_file(initial_content) + + try: + if exp_exc_type: + with pytest.raises(exp_exc_type) as exc_info: + + # The code to be tested + session_file.update(session_name, updates) + + 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) + + assert_session_file_content(session_file, initial_content) + + else: + + original_session = session_file.get(session_name) + + # The code to be tested + session_file.update(session_name, updates) + + updated_session = session_file.get(session_name) + exp_session = original_session + + def _update_exp_session(attr): + if attr in updates: + setattr(exp_session, attr, updates[attr]) + + _update_exp_session('host') + _update_exp_session('userid') + _update_exp_session('session_id') + _update_exp_session('ca_verify') + _update_exp_session('ca_cert_path') + assert_session_equal(updated_session, exp_session, session_name) + + finally: + delete_session_file(session_file) diff --git a/tests/function/utils.py b/tests/function/utils.py index d8e93b07..b4e62768 100644 --- a/tests/function/utils.py +++ b/tests/function/utils.py @@ -166,10 +166,6 @@ def call_zhmc_inline(args, env=None, faked_session=None): env = copy(env) # Unset the zhmc-specific env vars if not provided - if 'ZHMC_HOST' not in env: - env['ZHMC_HOST'] = None - if 'ZHMC_USERID' not in env: - env['ZHMC_USERID'] = None if faked_session: # Communicate the faked session object to the zhmc CLI code. # It is accessed in CmdContext.execute_cmd(). @@ -179,7 +175,13 @@ def call_zhmc_inline(args, env=None, faked_session=None): session_id = 'faked_session:{s}'.format( s='zhmcclient_mock.zhmccli_faked_session') env['ZHMC_SESSION_ID'] = session_id + env['ZHMC_HOST'] = faked_session.host + env['ZHMC_USERID'] = faked_session.userid else: + if 'ZHMC_HOST' not in env: + env['ZHMC_HOST'] = None + if 'ZHMC_USERID' not in env: + env['ZHMC_USERID'] = None if 'ZHMC_SESSION_ID' not in env: env['ZHMC_SESSION_ID'] = None diff --git a/tests/unit/test_helper.py b/tests/unit/test_helper.py index 2b5d8330..fbb44fe6 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() @@ -98,10 +100,11 @@ 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, - get_password=None, pdb=False) # nosec: B106 + params=None, host='host', userid='host', password='password', + no_verify=True, ca_certs=None, logon_source='options', + 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: with pytest.raises(click.exceptions.ClickException) as exc_info: @@ -189,10 +192,11 @@ 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, - get_password=None, pdb=False) # nosec: B106 + params=None, host='host', userid='host', password='password', + no_verify=True, ca_certs=None, logon_source='options', + 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: with pytest.raises(click.exceptions.ClickException) as exc_info: @@ -284,10 +288,11 @@ 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, - get_password=None, pdb=False) # nosec: B106 + params=None, host='host', userid='host', password='password', + no_verify=True, ca_certs=None, logon_source='options', + 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: with pytest.raises(click.exceptions.ClickException) as exc_info: @@ -436,10 +441,11 @@ 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, - get_password=None, pdb=False) # nosec: B106 + params=None, host='host', userid='host', password='password', + no_verify=True, ca_certs=None, logon_source='options', + 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: with pytest.raises(click.exceptions.ClickException) as exc_info: @@ -742,3 +748,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..b1d1a6eb 100644 --- a/zhmccli/_cmd_session.py +++ b/zhmccli/_cmd_session.py @@ -21,25 +21,107 @@ import zhmcclient from .zhmccli import cli -from ._helper import click_exception +from ._helper import click_exception, print_dicts, forbidden_option, \ + LogonSource +from ._session_file import HMCSession, HMCSessionFile, HMCSessionNotFound, \ + HMCSessionAlreadyExists, DEFAULT_SESSION_NAME, 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 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 + command. + """ + + +@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. + + The logon is performed unconditionally, regardless of an existing valid + session in the HMC session file or ZHMC_* environment variables. + An existing valid session in the HMC session file or ZHMC_* environment + variables is not logged off before logging on. + + 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_logon(cmd_ctx), logoff=False) + + +@session_group.command('logoff') +@click.pass_obj +def session_logoff(cmd_ctx): + """ + Log off from the HMC session defined in the HMC session file and delete + its 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_session_logoff(cmd_ctx), accept_no_session=True) @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. + + The logon is performed unconditionally, regardless of an existing valid + session in the HMC session file or ZHMC_* environment variables. + An existing valid session in the HMC session file or ZHMC_* environment + variables is not logged off before logging on. + + 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,28 +134,93 @@ def session_create(cmd_ctx): @click.pass_obj def session_delete(cmd_ctx): """ - Delete the current HMC session. + Deprecated: Log off from the HMC session defined in the ZHMC_* + environment variables and display commands to unset these environment + variables. + + This can be used for example with the 'eval' function of the bash shell + as follows, to immediately unset the 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 command. """ - cmd_ctx.execute_cmd(lambda: cmd_session_delete(cmd_ctx)) + cmd_ctx.execute_cmd( + lambda: cmd_session_delete(cmd_ctx), accept_no_session=True) -def cmd_session_create(cmd_ctx): - """Create an HMC session.""" - session = cmd_ctx.session +@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), setup_session=False) + + +def cmd_session_logon(cmd_ctx): + """Log on to the HMC, storing session data in session file.""" + session = cmd_ctx.session # zhmcclient.Session try: - # We need to first log off, to make the logon really create a new - # session. If we don't first log off, the session from the - # ZHMC_SESSION_ID env var will be used and no new session be created. - session.logoff() - session.logon(verify=True) + session.logon(always=True) except zhmcclient.Error as exc: raise click_exception(exc, cmd_ctx.error_format) + session_file = HMCSessionFile() + 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_session_logoff(cmd_ctx): + """Log off from the HMC session defined in session file.""" + + if cmd_ctx.logon_source not in (LogonSource.SESSION_FILE, LogonSource.NONE): + raise click_exception( + "Invalid logon source for the 'session logoff' command: " + f"{cmd_ctx.logon_source}", + cmd_ctx.error_format) + + session = cmd_ctx.session # zhmcclient.Session + if session is not None: + try: + session.logoff() # ignores invalid session ID + except zhmcclient.Error as exc: + raise click_exception(exc, cmd_ctx.error_format) + + session_file = HMCSessionFile() + session_name = cmd_ctx.session_name + try: + session_file.remove(session_name) + except HMCSessionNotFound as exc: + raise click_exception(exc, cmd_ctx.error_format) + cmd_ctx.spinner.stop() + print(f"Logged off from HMC session {session_name}") + + +def cmd_session_create(cmd_ctx): + """Log on to the HMC, storing session data in environment variables.""" + session = cmd_ctx.session # zhmcclient.Session + assert isinstance(session, zhmcclient.Session) + try: + session.logon(always=True) + except zhmcclient.Error as exc: + raise click_exception(exc, cmd_ctx.error_format) if session.verify_cert is False: no_verify = 'TRUE' @@ -85,6 +232,7 @@ def cmd_session_create(cmd_ctx): no_verify = None ca_certs = session.verify_cert + cmd_ctx.spinner.stop() click.echo(f"export ZHMC_HOST={session.host}") click.echo(f"export ZHMC_USERID={session.userid}") click.echo(f"export ZHMC_SESSION_ID={session.session_id}") @@ -99,12 +247,53 @@ def cmd_session_create(cmd_ctx): def cmd_session_delete(cmd_ctx): - """Delete the current HMC session.""" - session = cmd_ctx.session - try: - session.logoff() - except zhmcclient.Error as exc: - raise click_exception(exc, cmd_ctx.error_format) + """Log off from the HMC session defined in environment variables.""" + + if cmd_ctx.logon_source not in (LogonSource.ENVIRONMENT, LogonSource.NONE): + raise click_exception( + "Invalid logon source for the 'session delete' command: " + f"{cmd_ctx.logon_source}", + cmd_ctx.error_format) + + session = cmd_ctx.session # zhmcclient.Session + if session is not None: + try: + session.logoff() # ignores invalid session ID + except zhmcclient.Error as exc: + 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.""" + reason = ("this command does not log on to the HMC") + forbidden_option(cmd_ctx.params['host'], '--host', reason) + forbidden_option(cmd_ctx.params['userid'], '--userid', reason) + forbidden_option(cmd_ctx.params['no_verify'], '--no-verify', reason) + forbidden_option(cmd_ctx.params['ca_certs'], '--ca-certs', reason) + + session_file = HMCSessionFile() + 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..3944a0d2 100644 --- a/zhmccli/_helper.py +++ b/zhmccli/_helper.py @@ -24,6 +24,7 @@ import shutil import threading import re +from enum import Enum import jsonschema import click import click_spinner @@ -33,6 +34,8 @@ import zhmcclient import zhmcclient_mock +from ._session_file import HMCSessionNotFound, HMCSessionFile + # HMC API versions for new HMC versions # Can be used for comparison with Client.version_info() API_VERSION_HMC_2_11_1 = (1, 1) @@ -214,6 +217,30 @@ def __init__(self, output_format): super().__init__(msg) +class LogonSource(Enum): + """ + Enumeration defining where the logon data for the command came from. + """ + NONE = 1 # No logon data (session not found in HMC session file) + OPTIONS = 2 # Command line options (indicated by '-h' / '--host') + ENVIRONMENT = 3 # Environment variables (indicated by ZHMC_HOST) + SESSION_FILE = 4 # Existing session in HMC session file + + def __str__(self): + """ + Return a human readable string for the logon source. + """ + return LOGON_SOURCE_STRINGS[self] + + +LOGON_SOURCE_STRINGS = { + LogonSource.NONE: "none", + LogonSource.OPTIONS: "options", + LogonSource.ENVIRONMENT: "environment", + LogonSource.SESSION_FILE: "session file", +} + + class CmdContext: """ A context object we attach to the :class:`click.Context` object in its @@ -221,14 +248,17 @@ class CmdContext: data. """ - def __init__(self, host, userid, password, no_verify, ca_certs, - output_format, transpose, error_format, timestats, session_id, - get_password, pdb): + def __init__(self, params, host, userid, password, no_verify, ca_certs, + logon_source, session_name, output_format, transpose, + error_format, timestats, session_id, get_password, pdb): + self._params = params self._host = host self._userid = userid self._password = password self._no_verify = no_verify self._ca_certs = ca_certs + self._logon_source = logon_source + self._session_name = session_name self._output_format = output_format self._transpose = transpose self._error_format = error_format @@ -243,12 +273,23 @@ 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}, " \ + "logon_source={s._logon_source!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}, ...)". \ format(ctx=id(self), s=self, pw='...' if self._password else None) return ret + @property + def params(self): + """ + :term:`dict`: Click parameters defined for the zhmc command in + zhmccli.cli(). This allows to access the originally specified command + line options. + """ + return self._params + @property def host(self): """ @@ -280,6 +321,20 @@ def ca_certs(self): """ return self._ca_certs + @property + def logon_source(self): + """ + LogonSource: Where the logon data came from. + """ + return self._logon_source + + @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 +407,27 @@ def pdb(self): """ return self._pdb - def execute_cmd(self, cmd, logoff=True): + def execute_cmd(self, cmd, logoff=True, setup_session=True, + accept_no_session=False): """ Execute the command. + + Parameters: + logoff (bool): Log off at the end of the command. + setup_session (bool): Perform session setup before executring the + command. + accept_no_session (bool): Accept if the logon data does not provide + the data for performing session setup. """ - if self._session is None: + if self._session is None and setup_session: + session_file = HMCSessionFile() + 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 +439,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) + elif not accept_no_session: + 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 +473,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 +1691,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): """ - if options[option_key] != unspecified_value: - return options[option_key] - option_name = '--' + option_key.replace('_', '-') + 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. + """ + 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..8f59e921 --- /dev/null +++ b/zhmccli/_session_file.py @@ -0,0 +1,449 @@ +# 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 yaml +import jsonschema + +__all__ = ['HMCSessionFile', 'HMCSessionException', + 'HMCSessionNotFound', 'HMCSessionAlreadyExists', + 'HMCSessionFileNotFound', 'HMCSessionFileError', + 'HMCSessionFileFormatError', 'DEFAULT_SESSION_NAME'] + +BLANKED_OUT_STRING = '********' + +DEFAULT_SESSION_NAME = 'default' + +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 '{...}' + + +# The HMC session file that is normally used. +# Some unit/function tests specify another path name by passing the 'filepath' +# parameter to HMCSessionFile(). +# Some end2end tests specify another path name by setting the +# _ZHMC_TEST_SESSION_FILEPATH env. var. +SESSION_FILEPATH = '~/.zhmc_sessions.yml' + + +class HMCSessionFile: + """ + Access to an HMC session file. + """ + + def __init__(self, filepath=None): + """ + Parameters: + + filepath (str): Path name of the HMC session file. If None, the + path name in the env var _ZHMC_TEST_SESSION_FILEPATH is used. If + not set, the path in SESSION_FILEPATH is used. + """ + if filepath is None: + filepath = os.environ.get('_ZHMC_TEST_SESSION_FILEPATH', None) + if filepath is None: + filepath = SESSION_FILEPATH + self._filepath = os.path.expanduser(filepath) + self._data = None # File content, deferred loading + + def __repr__(self): + if self._data is None: + self._data = self._load() + 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 list(self): + """ + List all HMC sessions in the HMC session file. + + Returns: + dict of session name, HMCSession + + Raises: + HMCSessionFileError: HMC session file could not be created. + HMCSessionFileFormatError: Invalid YAML syntax in HMC session file. + HMCSessionFileFormatError: Invalid data format in HMC session file. + """ + 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 get(self, name=DEFAULT_SESSION_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: Session not found in HMC session file. + HMCSessionFileError: HMC session file could not be created. + HMCSessionFileFormatError: Invalid YAML syntax in HMC session file. + HMCSessionFileFormatError: Invalid data format in HMC session file. + """ + if self._data is None: + self._data = self._load() + try: + session_item = self._data[name] + except KeyError: + raise HMCSessionNotFound( + f"Session not found in HMC session file: {name}") + return HMCSession(**session_item) + + 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 session file. + HMCSessionFileError: HMC session file could not be created. + HMCSessionFileFormatError: Invalid YAML syntax in HMC session file. + HMCSessionFileFormatError: Invalid data format in HMC session file. + """ + if self._data is None: + self._data = self._load() + if name in self._data: + raise HMCSessionAlreadyExists( + f"Session already exists in HMC session file: {name}") + 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: Session not found in HMC session file. + HMCSessionFileError: HMC session file could not be created. + HMCSessionFileFormatError: Invalid YAML syntax in HMC session file. + HMCSessionFileFormatError: Invalid data format in HMC session file. + """ + if self._data is None: + self._data = self._load() + try: + del self._data[name] + except KeyError: + raise HMCSessionNotFound( + f"Session not found in HMC session file: {name}") + 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: Session not found in HMC session file. + HMCSessionFileError: HMC session file could not be created. + HMCSessionFileFormatError: Invalid YAML syntax in HMC session file. + HMCSessionFileFormatError: Invalid data format 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( + f"Session not found in HMC session file: {name}") + self._save(data) + self._data = data + + def _create(self): + """ + Create an empty HMC session file and return its empty data. + + Raises: + HMCSessionFileError: HMC session file could not be created. + """ + try: + with open(self._filepath, 'w', encoding="utf-8", + opener=session_file_opener) as fp: + fp.write("{}") + os.chmod(self._filepath, stat.S_IRUSR | stat.S_IWUSR) + except OSError as exc: + raise HMCSessionFileError( + f"The HMC session file {self._filepath!r} could not be " + f"created: {exc}") + return {} + + def _load(self): + """ + Load the HMC session file, validate it and return its data. + + Raises: + HMCSessionFileError: HMC session file could not be created. + HMCSessionFileFormatError: Invalid YAML syntax in HMC session file. + HMCSessionFileFormatError: Invalid data format in HMC session file. + """ + 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. + + Raises: + HMCSessionFileError: HMC session file could not be written. + """ + self._validate(data) + try: + with open(self._filepath, 'w', encoding="utf-8", + 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: + raise HMCSessionFileError( + f"The HMC session file {self._filepath!r} could not be " + f"written: {exc}") + + def _validate(self, data): + """ + Validate that the data conforms to the schema for the HMC session file. + + Raises: + HMCSessionFileFormatError: Invalid data format in 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 6b695b0f..aa441386 100644 --- a/zhmccli/zhmccli.py +++ b/zhmccli/zhmccli.py @@ -30,9 +30,16 @@ import zhmcclient import zhmcclient_mock +from ._session_file import HMCSessionFile, HMCSessionNotFound, \ + DEFAULT_SESSION_NAME + + 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, \ + LogonSource + urllib3.disable_warnings() @@ -87,28 +94,39 @@ @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 " + "not 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=None, + 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,51 @@ 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. + if session_name is None: + session_name = DEFAULT_SESSION_NAME + if host is not None: + # Logon data comes from command line options + logon_source = LogonSource.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 + logon_source = LogonSource.ENVIRONMENT + reason = "logon data from ZHMC_* environment variables is used" + forbidden_option(userid, '--userid', reason) + forbidden_option(no_verify, '--no-verify', reason) + forbidden_option(ca_certs, '--ca-certs', reason) + host = required_envvar('ZHMC_HOST') + 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 + reason = "logon data from HMC session file is used" + forbidden_option(userid, '--userid', reason) + forbidden_option(no_verify, '--no-verify', reason) + forbidden_option(ca_certs, '--ca-certs', reason) + session_file = HMCSessionFile() + try: + hmc_session = session_file.get(session_name) + except HMCSessionNotFound: + logon_source = LogonSource.NONE + host = None + userid = None + no_verify = None + ca_certs = None + session_id = None + else: + logon_source = LogonSource.SESSION_FILE + 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,22 +234,24 @@ 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 else: # We are processing an interactive command. # We apply the option defaults from the command line options. + logon_source = ctx.obj.logon_source + session_id = ctx.obj.session_id 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 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 +370,9 @@ 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) + logger = logging.getLogger('zhmcclient.hmc') + logger.debug("zhmc logon source: %s", logon_source) + 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 @@ -343,9 +410,12 @@ def get_password_via_prompt(host, userid): # 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) + ctx.obj = CmdContext( + params=ctx.params, host=host, userid=userid, password=password, + no_verify=no_verify, ca_certs=ca_certs, logon_source=logon_source, + session_name=session_name, output_format=output_format, + transpose=transpose, error_format=error_format, timestats=timestats, + session_id=session_id, get_password=get_password_via_prompt, pdb=pdb) # Invoke default command if ctx.invoked_subcommand is None: