diff --git a/plugins/module_utils/k8s/core.py b/plugins/module_utils/k8s/core.py new file mode 100644 index 00000000..2cff9563 --- /dev/null +++ b/plugins/module_utils/k8s/core.py @@ -0,0 +1,130 @@ +from distutils.version import LooseVersion +from typing import Optional + +from ansible.module_utils.basic import AnsibleModule +from ansible.module_utils.basic import missing_required_lib + + +class AnsibleK8SModule: + """A base module class for K8S modules. + + This class should be used instead of directly using AnsibleModule. If there + is a need for other methods or attributes to be proxied, they can be added + here. + """ + + default_settings = { + "check_k8s": True, + "module_class": AnsibleModule, + } + + def __init__(self, **kwargs) -> None: + local_settings = {} + for key in AnsibleK8SModule.default_settings: + try: + local_settings[key] = kwargs.pop(key) + except KeyError: + local_settings[key] = AnsibleK8SModule.default_settings[key] + self.settings = local_settings + + self._module = self.settings["module_class"](**kwargs) + + if self.settings["check_k8s"]: + self.requires("kubernetes") + self.has_at_least("kubernetes", "12.0.0", warn=True) + + @property + def check_mode(self): + return self._module.check_mode + + @property + def _diff(self): + return self._module._diff + + @property + def _name(self): + return self._module._name + + @property + def params(self): + return self._module.params + + def warn(self, *args, **kwargs): + return self._module.warn(*args, **kwargs) + + def deprecate(self, *args, **kwargs): + return self._module.deprecate(*args, **kwargs) + + def debug(self, *args, **kwargs): + return self._module.debug(*args, **kwargs) + + def exit_json(self, *args, **kwargs): + return self._module.exit_json(*args, **kwargs) + + def fail_json(self, *args, **kwargs): + return self._module.fail_json(*args, **kwargs) + + def _gather_versions(self) -> dict: + versions = {} + try: + import jsonpatch + + versions["jsonpatch"] = jsonpatch.__version__ + except ImportError: + pass + + try: + import kubernetes + + versions["kubernetes"] = kubernetes.__version__ + except ImportError: + pass + + try: + import yaml + + versions["pyyaml"] = yaml.__version__ + except ImportError: + pass + + return versions + + def has_at_least( + self, dependency: str, minimum: Optional[str] = None, warn: bool = False + ) -> bool: + """Check if a specific dependency is present at a minimum version. + + If a minimum version is not specified it will check only that the + dependency is present. Additionally, if ``warn`` is ``True``, a warning + will be emitted if the actual version is less than the specified + minimum version. + """ + dependencies = self._gather_versions() + current = dependencies.get(dependency) + if current is not None: + if minimum is None: + return True + supported = LooseVersion(current) >= LooseVersion(minimum) + if not supported and warn: + self.warn( + "{0}<{1} is not supported or tested. Some features may not work.".format( + dependency, minimum + ) + ) + return supported + return False + + def requires(self, dependency: str, minimum: Optional[str] = None) -> None: + """Fail if a specific dependency is not present at a minimum version. + + If a minimum version is not specified it will require only that the + dependency is present. This function calls ``fail_json()`` when the + dependency is not found at the required version and will stop module + execution. + """ + if not self.has_at_least(dependency, minimum): + if minimum is not None: + lib = "{0}>={1}".format(dependency, minimum) + else: + lib = dependency + self._module.fail_json(msg=missing_required_lib(lib)) diff --git a/test-requirements.txt b/test-requirements.txt index 880eec68..e20bfb9f 100644 --- a/test-requirements.txt +++ b/test-requirements.txt @@ -1,4 +1,6 @@ kubernetes-validate coverage==4.5.4 +mock pytest pytest-xdist +pytest-mock diff --git a/tests/sanity/refresh_ignore_files b/tests/sanity/refresh_ignore_files index ede60a83..6640baa5 100644 --- a/tests/sanity/refresh_ignore_files +++ b/tests/sanity/refresh_ignore_files @@ -9,28 +9,36 @@ module_dir = target_dir / "plugins" / "modules" module_utils_dir = target_dir / "plugins" / "module_utils" ignore_dir.mkdir(parents=True, exist_ok=True) -skip_list = [ +skip_list_2_6 = [ "compile-2.6!skip", # Py3.8+ + "import-2.6!skip", # Py3.8+ +] + +skip_list_3 = [ "compile-2.7!skip", # Py3.8+ "compile-3.5!skip", # Py3.8+ - "import-2.6!skip", # Py3.8+ "import-2.7!skip", # Py3.8+ "import-3.5!skip", # Py3.8+ "future-import-boilerplate!skip", # Py2 only "metaclass-boilerplate!skip", # Py2 only ] -for version in ["2.9", "2.10", "2.11", "2.12"]: +for version in ["2.9", "2.10", "2.11", "2.12", "2.13"]: ignore_file = ignore_dir / f"ignore-{version}.txt" ignore_content = ignore_file.read_text().split("\n") ignore_content.append(f"tests/sanity/refresh_ignore_files shebang!skip") - for f in module_dir.glob("*.py"): + if version == "2.13": + skip_list = skip_list_3 + else: + skip_list = skip_list_2_6 + skip_list_3 + + for f in module_dir.glob("**/*.py"): if f.is_symlink(): continue for test in skip_list: ignore_content.append(f"{f} {test}") - for f in module_utils_dir.glob("*.py"): + for f in module_utils_dir.glob("**/*.py"): if f.is_symlink(): continue for test in skip_list: diff --git a/tests/unit/conftest.py b/tests/unit/conftest.py new file mode 100644 index 00000000..20615adb --- /dev/null +++ b/tests/unit/conftest.py @@ -0,0 +1,44 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json +import sys +from io import BytesIO + +import pytest + +import ansible.module_utils.basic +from ansible.module_utils.six import string_types +from ansible.module_utils._text import to_bytes +from ansible.module_utils.common._collections_compat import MutableMapping + + +@pytest.fixture +def stdin(mocker, request): + old_args = ansible.module_utils.basic._ANSIBLE_ARGS + ansible.module_utils.basic._ANSIBLE_ARGS = None + old_argv = sys.argv + sys.argv = ["ansible_unittest"] + + if isinstance(request.param, string_types): + args = request.param + elif isinstance(request.param, MutableMapping): + if "ANSIBLE_MODULE_ARGS" not in request.param: + request.param = {"ANSIBLE_MODULE_ARGS": request.param} + if "_ansible_remote_tmp" not in request.param["ANSIBLE_MODULE_ARGS"]: + request.param["ANSIBLE_MODULE_ARGS"]["_ansible_remote_tmp"] = "/tmp" + if "_ansible_keep_remote_files" not in request.param["ANSIBLE_MODULE_ARGS"]: + request.param["ANSIBLE_MODULE_ARGS"]["_ansible_keep_remote_files"] = False + args = json.dumps(request.param) + else: + raise Exception("Malformed data to the stdin pytest fixture") + + fake_stdin = BytesIO(to_bytes(args, errors="surrogate_or_strict")) + mocker.patch("ansible.module_utils.basic.sys.stdin", mocker.MagicMock()) + mocker.patch("ansible.module_utils.basic.sys.stdin.buffer", fake_stdin) + + yield fake_stdin + + ansible.module_utils.basic._ANSIBLE_ARGS = old_args + sys.argv = old_argv diff --git a/tests/unit/module_utils/test_core.py b/tests/unit/module_utils/test_core.py new file mode 100644 index 00000000..189f1f95 --- /dev/null +++ b/tests/unit/module_utils/test_core.py @@ -0,0 +1,91 @@ +from __future__ import absolute_import, division, print_function + +__metaclass__ = type + +import json + +import kubernetes +import pytest + +from ansible_collections.kubernetes.core.plugins.module_utils.k8s.core import ( + AnsibleK8SModule, +) + +MINIMAL_K8S_VERSION = "12.0.0" +UNSUPPORTED_K8S_VERSION = "11.0.0" + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_no_warn(monkeypatch, stdin, capfd): + monkeypatch.setattr(kubernetes, "__version__", MINIMAL_K8S_VERSION) + + module = AnsibleK8SModule(argument_spec={}) + with pytest.raises(SystemExit): + module.exit_json() + out, err = capfd.readouterr() + + return_value = json.loads(out) + + assert return_value.get("exception") is None + assert return_value.get("warnings") is None + assert return_value.get("failed") is None + + +@pytest.mark.parametrize("stdin", [{}], indirect=["stdin"]) +def test_warn_on_k8s_version(monkeypatch, stdin, capfd): + monkeypatch.setattr(kubernetes, "__version__", UNSUPPORTED_K8S_VERSION) + + module = AnsibleK8SModule(argument_spec={}) + with pytest.raises(SystemExit): + module.exit_json() + out, err = capfd.readouterr() + + return_value = json.loads(out) + + assert return_value.get("warnings") is not None + warnings = return_value["warnings"] + assert len(warnings) == 1 + assert "kubernetes" in warnings[0] + assert MINIMAL_K8S_VERSION in warnings[0] + + +dependencies = [ + ["18.20.0", "12.0.1", False], + ["18.20.0", "18.20.0", True], + ["12.0.1", "18.20.0", True], +] + + +@pytest.mark.parametrize( + "stdin,desired,actual,result", [({}, *d) for d in dependencies], indirect=["stdin"] +) +def test_has_at_least(monkeypatch, stdin, desired, actual, result, capfd): + monkeypatch.setattr(kubernetes, "__version__", actual) + + module = AnsibleK8SModule(argument_spec={}) + + assert module.has_at_least("kubernetes", desired) is result + + +dependencies = [ + ["kubernetes", "18.20.0", "(kubernetes>=18.20.0)"], + ["foobar", "1.0.0", "(foobar>=1.0.0)"], + ["foobar", None, "(foobar)"], +] + + +@pytest.mark.parametrize( + "stdin,dependency,version,msg", [({}, *d) for d in dependencies], indirect=["stdin"] +) +def test_requires_fails_with_message( + monkeypatch, stdin, dependency, version, msg, capfd +): + monkeypatch.setattr(kubernetes, "__version__", "12.0.0") + module = AnsibleK8SModule(argument_spec={}) + with pytest.raises(SystemExit): + module.requires(dependency, version) + out, err = capfd.readouterr() + return_value = json.loads(out) + + assert return_value.get("failed") + assert msg in return_value.get("msg")