diff --git a/plugins/module_utils/dependency_checker.py b/plugins/module_utils/dependency_checker.py new file mode 100644 index 000000000..604e160e7 --- /dev/null +++ b/plugins/module_utils/dependency_checker.py @@ -0,0 +1,182 @@ +# -*- coding: utf-8 -*- +# Copyright (c) IBM Corporation 2025 +# 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 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. + +from __future__ import absolute_import, division, print_function +import sys +from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.log import SingletonLogger +from ansible_collections.ibm.ibm_zos_core.plugins.module_utils import version +logger = SingletonLogger().get_logger(verbosity=3) +try: + from zoautil_py import zsystem +except ImportError: + zsystem = None + +__metaclass__ = type + + +# ------------------------------------------------------------------------------ +# Compatibility Matrix by Collection Version +# ------------------------------------------------------------------------------ +COMPATIBILITY_MATRIX = { + "2.0.0": [ + {"min_zoau_version": "1.3.6.0", "min_python_version": "3.12", "min_zos_version": 2.5}, + ], + "2.1.0": [ + {"min_zoau_version": "1.4.1", "min_python_version": "3.12", "min_zos_version": 2.5}, + ], + "2.2.0": [ + {"min_zoau_version": "1.4.2", "min_python_version": "3.12", "min_zos_version": 2.5}, + ], +} + + +# ------------------------------------------------------------------------------ +# Version conversion helper +# ------------------------------------------------------------------------------ +def version_tuple(ver_str): + """Convert version string like '1.4.2' to tuple (1,4,2) for comparison.""" + return tuple(int(x) for x in ver_str.split(".")) + + +# ------------------------------------------------------------------------------ +# Version Fetchers +# ------------------------------------------------------------------------------ +def get_zoau_version(module=None): + try: + from zoautil_py import ZOAU_API_VERSION + return ZOAU_API_VERSION + except ImportError: + if module: + module.fail_json( + msg="Unable to import ZOAU. Please check PYTHONPATH, LIBPATH, ZOAU_HOME and PATH environment variables." + ) + return None + + +def get_python_version_info(): + return sys.version_info.major, sys.version_info.minor + + +def get_python_version(): + return f"{sys.version_info.major}.{sys.version_info.minor}.0" + + +import json + + +def get_zos_version(module=None): + if zsystem is None: + if module: + module.warn("Unable to import ZOAU zsystem module.") + logger.warning("Unable to import ZOAU zsystem module.") + return None + try: + sys_info = zsystem.zinfo("sys", json_format=True) + if isinstance(sys_info, str): + sys_info = json.loads(sys_info) + sys_data = ( + sys_info.get("data", {}).get("sys_info") + or sys_info.get("sys_info", {}) + ) + version_ = sys_data.get("product_version") + release = sys_data.get("product_release") + if version_ and release: + return f"{int(version_)}.{int(release)}" + except Exception as e: + if module: + module.warn(f"Failed to fetch z/OS version: {e}") + logger.warning("Failed to fetch z/OS version: %s", e) + return None + + +# ------------------------------------------------------------------------------ +# Dependency Validation +# ------------------------------------------------------------------------------ +def validate_dependencies(module): + logger.debug("Starting dependency validation process.") + zoau_version = get_zoau_version(module) + python_major, python_minor = get_python_version_info() + python_version_str = get_python_version() + zos_version_str = get_zos_version(module) + if zos_version_str is None: + logger.warning("get_zos_version() returned None. Possible ZOAU or module issue.") + else: + logger.debug("z/OS version retrieved successfully: %s", zos_version_str) + collection_version = version.__version__ + logger.debug( + "Detected versions - ZOAU: %s, Python: %s, z/OS: %s, Collection: %s", + zoau_version, + python_version_str, + zos_version_str, + collection_version, + ) + if not all([zoau_version, python_version_str, collection_version]): + logger.error("Failed to fetch one or more required dependencies.") + module.fail_json( + msg="Unable to fetch one or more required dependencies. Dependencies checked are ZOAU, Python, z/OS." + ) + + # Convert versions to proper types + current_python = (python_major, python_minor) + try: + zos_version = float(zos_version_str) if zos_version_str else None + except ValueError: + zos_version = None + + # Find compatibility entry + compat_list = COMPATIBILITY_MATRIX.get(collection_version, []) + if not compat_list: + logger.error("No compatibility info found for collection version: %s", collection_version) + module.fail_json(msg=f"No compatibility information for collection version: {collection_version}") + + matched_compat = None + for compat in compat_list: + if version_tuple(zoau_version) >= version_tuple(compat["min_zoau_version"]): + matched_compat = compat + break + + if not matched_compat: + msg = ( + f"Incompatible ZOAU version: {zoau_version}. " + f"For collection version {collection_version}, " + f"the minimum required ZOAU version is {compat_list[0]['min_zoau_version']}." + ) + logger.error(msg) + module.fail_json(msg=msg) + + # --- Validation logic --- + warnings = [] + max_python = (3, 13) + max_zos = 3.1 + + # --- Python warnings --- + min_python = version_tuple(matched_compat["min_python_version"]) + if current_python < min_python: + warnings.append(f"Python {python_version_str} is below the minimum tested version {min_python[0]}.{min_python[1]}.") + elif current_python > max_python: + warnings.append(f"Python {python_version_str} exceeds the maximum tested version {max_python[0]}.{max_python[1]}.") + + # --- z/OS warnings --- + min_zos = matched_compat["min_zos_version"] + if zos_version is not None: + if zos_version < min_zos: + warnings.append(f"z/OS {zos_version_str} is below the minimum tested version {min_zos}.") + elif zos_version > max_zos: + warnings.append(f"z/OS {zos_version_str} exceeds the maximum tested version {max_zos}.") + + # --- Warn and continue --- + for w in warnings: + logger.warning(w) + module.warn(w) + + logger.debug("Dependency validation process completed.") + return # do not exit, allow module to continue diff --git a/plugins/module_utils/version.py b/plugins/module_utils/version.py new file mode 100644 index 000000000..a12c5ccc1 --- /dev/null +++ b/plugins/module_utils/version.py @@ -0,0 +1,15 @@ +# -*- coding: utf-8 -*- + +# Copyright (c) IBM Corporation 2025 +# 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. +# Update this version with each new release of the collection + +__version__ = "2.0.0" diff --git a/plugins/modules/zos_apf.py b/plugins/modules/zos_apf.py index b6faf3161..8c8f34afa 100644 --- a/plugins/modules/zos_apf.py +++ b/plugins/modules/zos_apf.py @@ -326,6 +326,9 @@ from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.import_handler import ( ZOAUImportError, ) +from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.dependency_checker import ( + validate_dependencies, +) import traceback try: @@ -509,6 +512,7 @@ def main(): ['batch', 'operation'], ], ) + validate_dependencies(module) arg_defs = dict( library=dict(arg_type='str', required=False, aliases=['lib', 'name']), diff --git a/plugins/modules/zos_archive.py b/plugins/modules/zos_archive.py index f14c9923b..6f08d6e38 100644 --- a/plugins/modules/zos_archive.py +++ b/plugins/modules/zos_archive.py @@ -550,6 +550,9 @@ better_arg_parser, data_set, mvs_cmd, validation, encode) from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.import_handler import \ ZOAUImportError +from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.dependency_checker import ( + validate_dependencies, +) try: from zoautil_py import datasets @@ -1974,6 +1977,7 @@ def run_module(): ), supports_check_mode=True, ) + validate_dependencies(module) arg_defs = dict( src=dict(type='list', elements='str', required=True), diff --git a/plugins/modules/zos_backup_restore.py b/plugins/modules/zos_backup_restore.py index e1ca3198d..0b110ad9a 100644 --- a/plugins/modules/zos_backup_restore.py +++ b/plugins/modules/zos_backup_restore.py @@ -559,6 +559,9 @@ DataSet from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.import_handler import \ ZOAUImportError +from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.dependency_checker import ( + validate_dependencies, +) try: from zoautil_py import datasets @@ -623,6 +626,7 @@ def main(): ) module = AnsibleModule(argument_spec=module_args, supports_check_mode=False) + validate_dependencies(module) try: params = parse_and_validate_args(module.params) operation = params.get("operation") diff --git a/plugins/modules/zos_blockinfile.py b/plugins/modules/zos_blockinfile.py index 8be69c2ad..4426b25de 100644 --- a/plugins/modules/zos_blockinfile.py +++ b/plugins/modules/zos_blockinfile.py @@ -375,6 +375,9 @@ from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.import_handler import ( ZOAUImportError ) +from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.dependency_checker import ( + validate_dependencies, +) try: from zoautil_py import datasets @@ -566,6 +569,7 @@ def main(): ), mutually_exclusive=[['insertbefore', 'insertafter']], ) + validate_dependencies(module) arg_defs = dict( src=dict(arg_type='data_set_or_path', aliases=['path', 'destfile', 'name'], required=True), diff --git a/tests/unit/test_dependency_checker_utils.py b/tests/unit/test_dependency_checker_utils.py new file mode 100644 index 000000000..abbe09e71 --- /dev/null +++ b/tests/unit/test_dependency_checker_utils.py @@ -0,0 +1,130 @@ +# -*- coding: utf-8 -*- +# Copyright (c) IBM Corporation 2025 +# 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. + +import pytest +from unittest.mock import patch +from ansible_collections.ibm.ibm_zos_core.plugins.module_utils import dependency_checker +from ansible_collections.ibm.ibm_zos_core.plugins.module_utils import version +from ansible_collections.ibm.ibm_zos_core.plugins.module_utils.log import SingletonLogger + + +class FakeModule: + def __init__(self): + self.warned = [] + + def fail_json(self, **kwargs): + raise Exception(kwargs.get("msg", "fail_json called")) + + def warn(self, msg): + self.warned.append(msg) + + +# ------------------------------------------------------------------------------ +# Common fixture to silence logging during tests +# ------------------------------------------------------------------------------ +@pytest.fixture(autouse=True) +def patch_logger(monkeypatch): + logger_instance = SingletonLogger().get_logger(verbosity=3) + monkeypatch.setattr(logger_instance, "debug", lambda msg: None) + monkeypatch.setattr(logger_instance, "warning", lambda msg: None) + monkeypatch.setattr(logger_instance, "error", lambda msg: None) + yield + +# ------------------------------ +# Test: Python above max triggers warning +# ------------------------------ +def test_python_above_max(monkeypatch): + monkeypatch.setattr(dependency_checker, "get_zoau_version", lambda mod=None: "1.4.2") + monkeypatch.setattr(dependency_checker, "get_python_version_info", lambda: (3, 14)) + monkeypatch.setattr(dependency_checker, "get_python_version", lambda: "3.14.0") + monkeypatch.setattr(dependency_checker, "get_zos_version", lambda mod=None: "2.6") + monkeypatch.setattr(version, "__version__", "2.0.0") + + mod = FakeModule() + dependency_checker.validate_dependencies(mod) + assert any("Python 3.14.0 exceeds the maximum tested version" in w for w in mod.warned) + + +# ------------------------------ +# Test: z/OS above max triggers warning +# ------------------------------ +def test_zos_above_max(monkeypatch): + monkeypatch.setattr(dependency_checker, "get_zoau_version", lambda mod=None: "1.4.2") + monkeypatch.setattr(dependency_checker, "get_python_version_info", lambda: (3, 12)) + monkeypatch.setattr(dependency_checker, "get_python_version", lambda: "3.12.0") + monkeypatch.setattr(dependency_checker, "get_zos_version", lambda mod=None: "3.2") + monkeypatch.setattr(version, "__version__", "2.0.0") + + mod = FakeModule() + dependency_checker.validate_dependencies(mod) + assert any("z/OS 3.2 exceeds the maximum tested version" in w for w in mod.warned) + + +# ------------------------------ +# Test: versions within range pass without warning +# ------------------------------ +def test_versions_within_range(monkeypatch): + monkeypatch.setattr(dependency_checker, "get_zoau_version", lambda mod=None: "1.4.2") + monkeypatch.setattr(dependency_checker, "get_python_version_info", lambda: (3, 12)) + monkeypatch.setattr(dependency_checker, "get_python_version", lambda: "3.12.0") + monkeypatch.setattr(dependency_checker, "get_zos_version", lambda mod=None: "2.6") + monkeypatch.setattr(version, "__version__", "2.0.0") + + mod = FakeModule() + dependency_checker.validate_dependencies(mod) + assert mod.warned == [] + + +# ------------------------------ +# Test: Python below min triggers warning +# ------------------------------ +def test_python_below_min(monkeypatch): + monkeypatch.setattr(dependency_checker, "get_zoau_version", lambda mod=None: "1.4.2") + monkeypatch.setattr(dependency_checker, "get_python_version_info", lambda: (3, 11)) + monkeypatch.setattr(dependency_checker, "get_python_version", lambda: "3.11.0") + monkeypatch.setattr(dependency_checker, "get_zos_version", lambda mod=None: "2.6") + monkeypatch.setattr(version, "__version__", "2.0.0") + + mod = FakeModule() + dependency_checker.validate_dependencies(mod) + assert any("Python 3.11.0 is below the minimum tested version" in w for w in mod.warned) + + +# ------------------------------ +# Test: z/OS below min triggers warning +# ------------------------------ +def test_zos_below_min(monkeypatch): + monkeypatch.setattr(dependency_checker, "get_zoau_version", lambda mod=None: "1.4.2") + monkeypatch.setattr(dependency_checker, "get_python_version_info", lambda: (3, 12)) + monkeypatch.setattr(dependency_checker, "get_python_version", lambda: "3.12.0") + monkeypatch.setattr(dependency_checker, "get_zos_version", lambda mod=None: "2.4") + monkeypatch.setattr(version, "__version__", "2.0.0") + + mod = FakeModule() + dependency_checker.validate_dependencies(mod) + assert any("z/OS 2.4 is below the minimum tested version" in w for w in mod.warned) + + +# ------------------------------ +# Test: ZOAU below minimum version triggers failure +# ------------------------------ +def test_zoau_below_min_failsdcmdec(monkeypatch): + monkeypatch.setattr(dependency_checker, "get_zoau_version", lambda mod=None: "1.3.5") + monkeypatch.setattr(dependency_checker, "get_python_version_info", lambda: (3, 12)) + monkeypatch.setattr(dependency_checker, "get_python_version", lambda: "3.12.0") + monkeypatch.setattr(dependency_checker, "get_zos_version", lambda mod=None: "2.6") + monkeypatch.setattr(version, "__version__", "2.0.0") + + mod = FakeModule() + with pytest.raises(Exception) as exc: + dependency_checker.validate_dependencies(mod) + assert "Incompatible ZOAU version" in str(exc.value)