From 804b9ab57cb4fb915bea071ea280fde1fe6d96b9 Mon Sep 17 00:00:00 2001 From: Bikouo Aubin <79859644+abikouo@users.noreply.github.com> Date: Thu, 12 Jan 2023 10:46:42 +0100 Subject: [PATCH] Helm - Fix issue with alternative kubeconfig (#563) Helm - Fix issue with alternative kubeconfig SUMMARY closes #538 ISSUE TYPE Bugfix Pull Request COMPONENT NAME helm modules Reviewed-by: Mike Graves --- ...en-alternative-kubeconfig-is-provided.yaml | 3 + plugins/module_utils/helm.py | 307 +++++++----- plugins/module_utils/helm_args_common.py | 2 - plugins/modules/helm.py | 68 ++- plugins/modules/helm_info.py | 22 +- plugins/modules/helm_plugin.py | 38 +- plugins/modules/helm_plugin_info.py | 14 +- plugins/modules/helm_pull.py | 22 +- plugins/modules/helm_repository.py | 19 +- plugins/modules/helm_template.py | 13 +- .../targets/helm/defaults/main.yml | 8 +- .../targets/helm/tasks/install.yml | 2 +- .../targets/helm/tasks/run_test.yml | 4 +- ...ory_kubeconfig.yml => tests_helm_auth.yml} | 80 ++- .../targets/helm/tasks/tests_helm_diff.yml | 7 +- .../helm/tasks/tests_helm_kubeconfig.yml | 22 + .../from_in_memory_kubeconfig.yml | 9 + .../from_kubeconfig_with_cacert.yml | 76 +++ .../from_kubeconfig_with_validate_certs.yml | 67 +++ tests/unit/module_utils/test_helm.py | 473 +++++++++++++----- 20 files changed, 872 insertions(+), 384 deletions(-) create mode 100644 changelogs/fragments/562-helm-fix-issue-when-alternative-kubeconfig-is-provided.yaml rename tests/integration/targets/helm/tasks/{tests_in_memory_kubeconfig.yml => tests_helm_auth.yml} (57%) create mode 100644 tests/integration/targets/helm/tasks/tests_helm_kubeconfig.yml create mode 100644 tests/integration/targets/helm/tasks/tests_kubeconfig/from_in_memory_kubeconfig.yml create mode 100644 tests/integration/targets/helm/tasks/tests_kubeconfig/from_kubeconfig_with_cacert.yml create mode 100644 tests/integration/targets/helm/tasks/tests_kubeconfig/from_kubeconfig_with_validate_certs.yml diff --git a/changelogs/fragments/562-helm-fix-issue-when-alternative-kubeconfig-is-provided.yaml b/changelogs/fragments/562-helm-fix-issue-when-alternative-kubeconfig-is-provided.yaml new file mode 100644 index 00000000..bd7626af --- /dev/null +++ b/changelogs/fragments/562-helm-fix-issue-when-alternative-kubeconfig-is-provided.yaml @@ -0,0 +1,3 @@ +--- +bugfixes: + - Helm - Fix issue with alternative kubeconfig provided with validate_certs=False (https://github.com/ansible-collections/kubernetes.core/issues/538). diff --git a/plugins/module_utils/helm.py b/plugins/module_utils/helm.py index 6ca44aef..58c75ffb 100644 --- a/plugins/module_utils/helm.py +++ b/plugins/module_utils/helm.py @@ -12,9 +12,14 @@ import tempfile import traceback import re import json +import copy from ansible.module_utils.basic import missing_required_lib from ansible.module_utils.six import string_types +from ansible_collections.kubernetes.core.plugins.module_utils.version import ( + LooseVersion, +) +from ansible.module_utils.basic import AnsibleModule try: import yaml @@ -26,119 +31,7 @@ except ImportError: HAS_YAML = False -def prepare_helm_environ_update(module): - environ_update = {} - kubeconfig_path = None - if module.params.get("kubeconfig") is not None: - kubeconfig = module.params.get("kubeconfig") - if isinstance(kubeconfig, string_types): - kubeconfig_path = kubeconfig - elif isinstance(kubeconfig, dict): - fd, kubeconfig_path = tempfile.mkstemp() - with os.fdopen(fd, "w") as fp: - json.dump(kubeconfig, fp) - module.add_cleanup_file(kubeconfig_path) - if module.params.get("context") is not None: - environ_update["HELM_KUBECONTEXT"] = module.params.get("context") - if module.params.get("release_namespace"): - environ_update["HELM_NAMESPACE"] = module.params.get("release_namespace") - if module.params.get("api_key"): - environ_update["HELM_KUBETOKEN"] = module.params["api_key"] - if module.params.get("host"): - environ_update["HELM_KUBEAPISERVER"] = module.params["host"] - if module.params.get("validate_certs") is False or module.params.get("ca_cert"): - kubeconfig_path = write_temp_kubeconfig( - module.params["host"], - validate_certs=module.params["validate_certs"], - ca_cert=module.params["ca_cert"], - ) - module.add_cleanup_file(kubeconfig_path) - if kubeconfig_path is not None: - environ_update["KUBECONFIG"] = kubeconfig_path - - return environ_update - - -def run_helm(module, command, fails_on_error=True): - if not HAS_YAML: - module.fail_json(msg=missing_required_lib("PyYAML"), exception=YAML_IMP_ERR) - - environ_update = prepare_helm_environ_update(module) - rc, out, err = module.run_command(command, environ_update=environ_update) - if fails_on_error and rc != 0: - module.fail_json( - msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format( - rc, out, err - ), - stdout=out, - stderr=err, - command=command, - ) - return rc, out, err - - -def get_values(module, command, release_name, get_all=False): - """ - Get Values from deployed release - """ - if not HAS_YAML: - module.fail_json(msg=missing_required_lib("PyYAML"), exception=YAML_IMP_ERR) - - get_command = command + " get values --output=yaml " + release_name - - if get_all: - get_command += " -a" - - rc, out, err = run_helm(module, get_command) - # Helm 3 return "null" string when no values are set - if out.rstrip("\n") == "null": - return {} - return yaml.safe_load(out) - - -def write_temp_kubeconfig(server, validate_certs=True, ca_cert=None): - # Workaround until https://github.com/helm/helm/pull/8622 is merged - content = { - "apiVersion": "v1", - "kind": "Config", - "clusters": [{"cluster": {"server": server}, "name": "generated-cluster"}], - "contexts": [ - {"context": {"cluster": "generated-cluster"}, "name": "generated-context"} - ], - "current-context": "generated-context", - } - - if not validate_certs: - content["clusters"][0]["cluster"]["insecure-skip-tls-verify"] = True - if ca_cert: - content["clusters"][0]["cluster"]["certificate-authority"] = ca_cert - - _fd, file_name = tempfile.mkstemp() - with os.fdopen(_fd, "w") as fp: - yaml.dump(content, fp) - return file_name - - -def get_helm_plugin_list(module, helm_bin=None): - """ - Return `helm plugin list` - """ - if not helm_bin: - return [] - helm_plugin_list = helm_bin + " plugin list" - rc, out, err = run_helm(module, helm_plugin_list) - if rc != 0 or (out == "" and err == ""): - module.fail_json( - msg="Failed to get Helm plugin info", - command=helm_plugin_list, - stdout=out, - stderr=err, - rc=rc, - ) - return (rc, out, err) - - -def parse_helm_plugin_list(module, output=None): +def parse_helm_plugin_list(output=None): """ Parse `helm plugin list`, return list of plugins """ @@ -160,20 +53,180 @@ def parse_helm_plugin_list(module, output=None): return ret -def get_helm_version(module, helm_bin): +def write_temp_kubeconfig(server, validate_certs=True, ca_cert=None, kubeconfig=None): + # Workaround until https://github.com/helm/helm/pull/8622 is merged + content = { + "apiVersion": "v1", + "kind": "Config", + "clusters": [{"cluster": {"server": server}, "name": "generated-cluster"}], + "contexts": [ + {"context": {"cluster": "generated-cluster"}, "name": "generated-context"} + ], + "current-context": "generated-context", + } + if kubeconfig: + content = copy.deepcopy(kubeconfig) - helm_version_command = helm_bin + " version" - rc, out, err = module.run_command(helm_version_command) - m = re.match(r'version.BuildInfo{Version:"v([0-9\.]*)",', out) - if m: - return m.group(1) - m = re.match(r'Client: &version.Version{SemVer:"v([0-9\.]*)", ', out) - if m: - return m.group(1) - return None + for cluster in content["clusters"]: + if server: + cluster["cluster"]["server"] = server + if not validate_certs: + cluster["cluster"]["insecure-skip-tls-verify"] = True + if ca_cert: + cluster["cluster"]["certificate-authority"] = ca_cert + return content -def get_helm_binary(module): - return module.params.get("binary_path") or module.get_bin_path( - "helm", required=True - ) +class AnsibleHelmModule(object): + + """ + An Ansible module class for Kubernetes.core helm modules + """ + + def __init__(self, **kwargs): + + self._module = None + if "module" in kwargs: + self._module = kwargs.get("module") + else: + self._module = AnsibleModule(**kwargs) + + self.helm_env = None + + def __getattr__(self, name): + return getattr(self._module, name) + + @property + def params(self): + return self._module.params + + def _prepare_helm_environment(self): + param_to_env_mapping = [ + ("context", "HELM_KUBECONTEXT"), + ("release_namespace", "HELM_NAMESPACE"), + ("api_key", "HELM_KUBETOKEN"), + ("host", "HELM_KUBEAPISERVER"), + ] + + env_update = {} + for p, env in param_to_env_mapping: + if self.params.get(p): + env_update[env] = self.params.get(p) + + kubeconfig_content = None + kubeconfig = self.params.get("kubeconfig") + if kubeconfig: + if isinstance(kubeconfig, string_types): + with open(kubeconfig) as fd: + kubeconfig_content = yaml.safe_load(fd) + elif isinstance(kubeconfig, dict): + kubeconfig_content = kubeconfig + + if self.params.get("ca_cert"): + ca_cert = self.params.get("ca_cert") + if LooseVersion(self.get_helm_version()) < LooseVersion("3.5.0"): + # update certs from kubeconfig + kubeconfig_content = write_temp_kubeconfig( + server=self.params.get("host"), + ca_cert=ca_cert, + kubeconfig=kubeconfig_content, + ) + else: + env_update["HELM_KUBECAFILE"] = ca_cert + + if self.params.get("validate_certs") is False: + validate_certs = self.params.get("validate_certs") + if LooseVersion(self.get_helm_version()) < LooseVersion("3.10.0"): + # update certs from kubeconfig + kubeconfig_content = write_temp_kubeconfig( + server=self.params.get("host"), + validate_certs=validate_certs, + kubeconfig=kubeconfig_content, + ) + else: + env_update["HELM_KUBEINSECURE_SKIP_TLS_VERIFY"] = "true" + + if kubeconfig_content: + fd, kubeconfig_path = tempfile.mkstemp() + with os.fdopen(fd, "w") as fp: + json.dump(kubeconfig_content, fp) + + env_update["KUBECONFIG"] = kubeconfig_path + self.add_cleanup_file(kubeconfig_path) + + return env_update + + @property + def env_update(self): + if self.helm_env is None: + self.helm_env = self._prepare_helm_environment() + return self.helm_env + + def run_helm_command(self, command, fails_on_error=True): + if not HAS_YAML: + self.fail_json(msg=missing_required_lib("PyYAML"), exception=YAML_IMP_ERR) + + rc, out, err = self.run_command(command, environ_update=self.env_update) + if fails_on_error and rc != 0: + self.fail_json( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format( + rc, out, err + ), + stdout=out, + stderr=err, + command=command, + ) + return rc, out, err + + def get_helm_binary(self): + return self.params.get("binary_path") or self.get_bin_path( + "helm", required=True + ) + + def get_helm_version(self): + + command = self.get_helm_binary() + " version" + rc, out, err = self.run_command(command) + m = re.match(r'version.BuildInfo{Version:"v([0-9\.]*)",', out) + if m: + return m.group(1) + m = re.match(r'Client: &version.Version{SemVer:"v([0-9\.]*)", ', out) + if m: + return m.group(1) + return None + + def get_values(self, release_name, get_all=False): + """ + Get Values from deployed release + """ + if not HAS_YAML: + self.fail_json(msg=missing_required_lib("PyYAML"), exception=YAML_IMP_ERR) + + get_command = ( + self.get_helm_binary() + " get values --output=yaml " + release_name + ) + + if get_all: + get_command += " -a" + + rc, out, err = self.run_helm_command(get_command) + # Helm 3 return "null" string when no values are set + if out.rstrip("\n") == "null": + return {} + return yaml.safe_load(out) + + def get_helm_plugin_list(self): + """ + Return `helm plugin list` + """ + helm_plugin_list = self.get_helm_binary() + " plugin list" + rc, out, err = self.run_helm_command(helm_plugin_list) + if rc != 0 or (out == "" and err == ""): + self.fail_json( + msg="Failed to get Helm plugin info", + command=helm_plugin_list, + stdout=out, + stderr=err, + rc=rc, + ) + return (rc, out, err, helm_plugin_list) diff --git a/plugins/module_utils/helm_args_common.py b/plugins/module_utils/helm_args_common.py index df4d8f33..ebf8e9f5 100644 --- a/plugins/module_utils/helm_args_common.py +++ b/plugins/module_utils/helm_args_common.py @@ -39,6 +39,4 @@ HELM_AUTH_ARG_SPEC = dict( HELM_AUTH_MUTUALLY_EXCLUSIVE = [ ("context", "ca_cert"), ("context", "validate_certs"), - ("kubeconfig", "ca_cert"), - ("kubeconfig", "validate_certs"), ] diff --git a/plugins/modules/helm.py b/plugins/modules/helm.py index fe3e98a4..eabe7a8a 100644 --- a/plugins/modules/helm.py +++ b/plugins/modules/helm.py @@ -350,14 +350,10 @@ except ImportError: IMP_YAML_ERR = traceback.format_exc() IMP_YAML = False -from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.basic import missing_required_lib from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( - run_helm, - get_values, - get_helm_plugin_list, + AnsibleHelmModule, parse_helm_plugin_list, - get_helm_version, - get_helm_binary, ) from ansible_collections.kubernetes.core.plugins.module_utils.helm_args_common import ( HELM_AUTH_ARG_SPEC, @@ -376,39 +372,41 @@ def get_release(state, release_name): return None -def get_release_status(module, command, release_name): +def get_release_status(module, release_name): """ Get Release state from deployed release """ - list_command = command + " list --output=yaml --filter " + release_name + list_command = ( + module.get_helm_binary() + " list --output=yaml --filter " + release_name + ) - rc, out, err = run_helm(module, list_command) + rc, out, err = module.run_helm_command(list_command) release = get_release(yaml.safe_load(out), release_name) if release is None: # not install return None - release["values"] = get_values(module, command, release_name) + release["values"] = module.get_values(release_name) return release -def run_repo_update(module, command): +def run_repo_update(module): """ Run Repo update """ - repo_update_command = command + " repo update" - rc, out, err = run_helm(module, repo_update_command) + repo_update_command = module.get_helm_binary() + " repo update" + rc, out, err = module.run_helm_command(repo_update_command) -def run_dep_update(module, command, chart_ref): +def run_dep_update(module, chart_ref): """ Run dependency update """ - dep_update = command + " dependency update " + chart_ref - rc, out, err = run_helm(module, dep_update) + dep_update = module.get_helm_binary() + " dependency update " + chart_ref + rc, out, err = module.run_helm_command(dep_update) def fetch_chart_info(module, command, chart_ref): @@ -417,7 +415,7 @@ def fetch_chart_info(module, command, chart_ref): """ inspect_command = command + " show chart " + chart_ref - rc, out, err = run_helm(module, inspect_command) + rc, out, err = module.run_helm_command(inspect_command) return yaml.safe_load(out) @@ -537,13 +535,13 @@ def load_values_files(values_files): return values -def get_plugin_version(helm_bin, plugin): +def get_plugin_version(plugin): """ Check if helm plugin is installed and return corresponding version """ - rc, output, err = get_helm_plugin_list(module, helm_bin=helm_bin) - out = parse_helm_plugin_list(module, output=output.splitlines()) + rc, output, err, command = module.get_helm_plugin_list() + out = parse_helm_plugin_list(output=output.splitlines()) if not out: return None @@ -556,7 +554,6 @@ def get_plugin_version(helm_bin, plugin): def helmdiff_check( module, - helm_cmd, release_name, chart_ref, release_values, @@ -568,7 +565,7 @@ def helmdiff_check( """ Use helm diff to determine if a release would change by upgrading a chart. """ - cmd = helm_cmd + " diff upgrade" + cmd = module.get_helm_binary() + " diff upgrade" cmd += " " + release_name cmd += " " + chart_ref @@ -584,12 +581,13 @@ def helmdiff_check( with open(path, "w") as yaml_file: yaml.dump(release_values, yaml_file, default_flow_style=False) cmd += " -f=" + path + module.add_cleanup_file(path) if values_files: for values_file in values_files: cmd += " -f=" + values_file - rc, out, err = run_helm(module, cmd) + rc, out, err = module.run_helm_command(cmd) return (len(out.strip()) > 0, out.strip()) @@ -652,7 +650,7 @@ def argument_spec(): def main(): global module - module = AnsibleModule( + module = AnsibleHelmModule( argument_spec=argument_spec(), required_if=[ ("release_state", "present", ["release_name", "chart_ref"]), @@ -660,7 +658,6 @@ def main(): ], mutually_exclusive=[ ("context", "ca_cert"), - ("kubeconfig", "ca_cert"), ("replace", "history_max"), ("wait_timeout", "timeout"), ], @@ -696,24 +693,20 @@ def main(): history_max = module.params.get("history_max") timeout = module.params.get("timeout") - helm_cmd_common = get_helm_binary(module) - helm_bin = helm_cmd_common - if update_repo_cache: - run_repo_update(module, helm_cmd_common) + run_repo_update(module) # Get real/deployed release status - release_status = get_release_status(module, helm_cmd_common, release_name) + release_status = get_release_status(module, release_name) - # keep helm_cmd_common for get_release_status in module_exit_json - helm_cmd = helm_cmd_common + helm_cmd = module.get_helm_binary() opt_result = {} if release_state == "absent" and release_status is not None: if replace: module.fail_json(msg="replace is not applicable when state is absent") if wait: - helm_version = get_helm_version(module, helm_cmd_common) + helm_version = module.get_helm_version() if LooseVersion(helm_version) < LooseVersion("3.7.0"): opt_result["warnings"] = [] opt_result["warnings"].append( @@ -746,7 +739,7 @@ def main(): if not chart_repo_url and not re.fullmatch( r"^http[s]*://[\w.:/?&=-]+$", chart_ref ): - run_dep_update(module, helm_cmd_common, chart_ref) + run_dep_update(module, chart_ref) # To not add --dependency-update option in the deploy function dependency_update = False @@ -790,7 +783,7 @@ def main(): else: - helm_diff_version = get_plugin_version(helm_bin, "diff") + helm_diff_version = get_plugin_version("diff") if helm_diff_version and ( not chart_repo_url or ( @@ -800,7 +793,6 @@ def main(): ): (would_change, prepared) = helmdiff_check( module, - helm_cmd_common, release_name, chart_ref, release_values, @@ -866,13 +858,13 @@ def main(): **opt_result, ) - rc, out, err = run_helm(module, helm_cmd) + rc, out, err = module.run_helm_command(helm_cmd) module.exit_json( changed=changed, stdout=out, stderr=err, - status=get_release_status(module, helm_cmd_common, release_name), + status=get_release_status(module, release_name), command=helm_cmd, **opt_result, ) diff --git a/plugins/modules/helm_info.py b/plugins/modules/helm_info.py index dbe650bb..77fa1ba8 100644 --- a/plugins/modules/helm_info.py +++ b/plugins/modules/helm_info.py @@ -131,11 +131,9 @@ except ImportError: IMP_YAML_ERR = traceback.format_exc() IMP_YAML = False -from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.basic import missing_required_lib from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( - run_helm, - get_values, - get_helm_binary, + AnsibleHelmModule, ) from ansible_collections.kubernetes.core.plugins.module_utils.helm_args_common import ( HELM_AUTH_ARG_SPEC, @@ -153,10 +151,8 @@ def get_release(state, release_name): # Get Release state from deployed release -def get_release_status( - module, command, release_name, release_state, get_all_values=False -): - list_command = command + " list --output=yaml" +def get_release_status(module, release_name, release_state, get_all_values=False): + list_command = module.get_helm_binary() + " list --output=yaml" valid_release_states = [ "all", @@ -173,7 +169,7 @@ def get_release_status( list_command += " --%s" % local_release_state list_command += " --filter " + release_name - rc, out, err = run_helm(module, list_command) + rc, out, err = module.run_helm_command(list_command) if rc != 0: module.fail_json( @@ -188,7 +184,7 @@ def get_release_status( if release is None: # not install return None - release["values"] = get_values(module, command, release_name, get_all_values) + release["values"] = module.get_values(release_name, get_all_values) return release @@ -209,7 +205,7 @@ def argument_spec(): def main(): global module - module = AnsibleModule( + module = AnsibleHelmModule( argument_spec=argument_spec(), mutually_exclusive=HELM_AUTH_MUTUALLY_EXCLUSIVE, supports_check_mode=True, @@ -222,10 +218,8 @@ def main(): release_state = module.params.get("release_state") get_all_values = module.params.get("get_all_values") - helm_cmd_common = get_helm_binary(module) - release_status = get_release_status( - module, helm_cmd_common, release_name, release_state, get_all_values + module, release_name, release_state, get_all_values ) if release_status is not None: diff --git a/plugins/modules/helm_plugin.py b/plugins/modules/helm_plugin.py index ad8a55ea..795dbf29 100644 --- a/plugins/modules/helm_plugin.py +++ b/plugins/modules/helm_plugin.py @@ -109,12 +109,9 @@ rc: """ import copy -from ansible.module_utils.basic import AnsibleModule from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( - run_helm, - get_helm_plugin_list, + AnsibleHelmModule, parse_helm_plugin_list, - get_helm_binary, ) from ansible_collections.kubernetes.core.plugins.module_utils.helm_args_common import ( HELM_AUTH_ARG_SPEC, @@ -152,7 +149,7 @@ def mutually_exclusive(): def main(): - module = AnsibleModule( + module = AnsibleHelmModule( argument_spec=argument_spec(), supports_check_mode=True, required_if=[ @@ -164,9 +161,8 @@ def main(): ) state = module.params.get("state") - helm_bin = get_helm_binary(module) - helm_cmd_common = helm_bin + " plugin" + helm_cmd_common = module.get_helm_binary() + " plugin" if state == "present": helm_cmd_common += " install %s" % module.params.get("plugin_path") @@ -174,7 +170,9 @@ def main(): if plugin_version is not None: helm_cmd_common += " --version=%s" % plugin_version if not module.check_mode: - rc, out, err = run_helm(module, helm_cmd_common, fails_on_error=False) + rc, out, err = module.run_helm_command( + helm_cmd_common, fails_on_error=False + ) else: rc, out, err = (0, "", "") @@ -208,15 +206,15 @@ def main(): ) elif state == "absent": plugin_name = module.params.get("plugin_name") - rc, output, err = get_helm_plugin_list(module, helm_bin=helm_bin) - out = parse_helm_plugin_list(module, output=output.splitlines()) + rc, output, err, command = module.get_helm_plugin_list() + out = parse_helm_plugin_list(output=output.splitlines()) if not out: module.exit_json( failed=False, changed=False, msg="Plugin not found or is already uninstalled", - command=helm_cmd_common + " list", + command=command, stdout=output, stderr=err, rc=rc, @@ -232,7 +230,7 @@ def main(): failed=False, changed=False, msg="Plugin not found or is already uninstalled", - command=helm_cmd_common + " list", + command=command, stdout=output, stderr=err, rc=rc, @@ -240,7 +238,9 @@ def main(): helm_uninstall_cmd = "%s uninstall %s" % (helm_cmd_common, plugin_name) if not module.check_mode: - rc, out, err = run_helm(module, helm_uninstall_cmd, fails_on_error=False) + rc, out, err = module.run_helm_command( + helm_uninstall_cmd, fails_on_error=False + ) else: rc, out, err = (0, "", "") @@ -262,15 +262,15 @@ def main(): ) elif state == "latest": plugin_name = module.params.get("plugin_name") - rc, output, err = get_helm_plugin_list(module, helm_bin=helm_bin) - out = parse_helm_plugin_list(module, output=output.splitlines()) + rc, output, err, command = module.get_helm_plugin_list() + out = parse_helm_plugin_list(output=output.splitlines()) if not out: module.exit_json( failed=False, changed=False, msg="Plugin not found", - command=helm_cmd_common + " list", + command=command, stdout=output, stderr=err, rc=rc, @@ -286,7 +286,7 @@ def main(): failed=False, changed=False, msg="Plugin not found", - command=helm_cmd_common + " list", + command=command, stdout=output, stderr=err, rc=rc, @@ -294,7 +294,9 @@ def main(): helm_update_cmd = "%s update %s" % (helm_cmd_common, plugin_name) if not module.check_mode: - rc, out, err = run_helm(module, helm_update_cmd, fails_on_error=False) + rc, out, err = module.run_helm_command( + helm_update_cmd, fails_on_error=False + ) else: rc, out, err = (0, "", "") diff --git a/plugins/modules/helm_plugin_info.py b/plugins/modules/helm_plugin_info.py index fefe3645..3b9fcd18 100644 --- a/plugins/modules/helm_plugin_info.py +++ b/plugins/modules/helm_plugin_info.py @@ -71,11 +71,9 @@ rc: """ import copy -from ansible.module_utils.basic import AnsibleModule from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( - get_helm_plugin_list, parse_helm_plugin_list, - get_helm_binary, + AnsibleHelmModule, ) from ansible_collections.kubernetes.core.plugins.module_utils.helm_args_common import ( HELM_AUTH_ARG_SPEC, @@ -94,21 +92,19 @@ def main(): ) ) - module = AnsibleModule( + module = AnsibleHelmModule( argument_spec=argument_spec, mutually_exclusive=HELM_AUTH_MUTUALLY_EXCLUSIVE, supports_check_mode=True, ) - helm_bin = get_helm_binary(module) - plugin_name = module.params.get("plugin_name") plugin_list = [] - rc, output, err = get_helm_plugin_list(module, helm_bin=helm_bin) + rc, output, err, command = module.get_helm_plugin_list() - out = parse_helm_plugin_list(module, output=output.splitlines()) + out = parse_helm_plugin_list(output=output.splitlines()) for line in out: if plugin_name is None: @@ -125,7 +121,7 @@ def main(): module.exit_json( changed=True, - command=helm_bin + " plugin list", + command=command, stdout=output, stderr=err, rc=rc, diff --git a/plugins/modules/helm_pull.py b/plugins/modules/helm_pull.py index 5bd3233a..03edb97e 100644 --- a/plugins/modules/helm_pull.py +++ b/plugins/modules/helm_pull.py @@ -169,10 +169,8 @@ rc: sample: 1 """ -from ansible.module_utils.basic import AnsibleModule from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( - run_helm, - get_helm_version, + AnsibleHelmModule, ) from ansible_collections.kubernetes.core.plugins.module_utils.version import ( LooseVersion, @@ -201,7 +199,7 @@ def main(): chart_ssl_key_file=dict(type="path"), binary_path=dict(type="path"), ) - module = AnsibleModule( + module = AnsibleHelmModule( argument_spec=argspec, supports_check_mode=True, required_by=dict( @@ -211,15 +209,7 @@ def main(): mutually_exclusive=[("chart_version", "chart_devel")], ) - bin_path = module.params.get("binary_path") - if bin_path is not None: - helm_cmd_common = bin_path - else: - helm_cmd_common = "helm" - - helm_cmd_common = module.get_bin_path(helm_cmd_common, required=True) - - helm_version = get_helm_version(module, helm_cmd_common) + helm_version = module.get_helm_version() if LooseVersion(helm_version) < LooseVersion("3.0.0"): module.fail_json( msg="This module requires helm >= 3.0.0, current version is {0}".format( @@ -279,10 +269,12 @@ def main(): helm_pull_opts.append("--{0}".format(v["key"])) helm_cmd_common = "{0} pull {1} {2}".format( - helm_cmd_common, module.params.get("chart_ref"), " ".join(helm_pull_opts) + module.get_helm_binary(), + module.params.get("chart_ref"), + " ".join(helm_pull_opts), ) if not module.check_mode: - rc, out, err = run_helm(module, helm_cmd_common, fails_on_error=False) + rc, out, err = module.run_helm_command(helm_cmd_common, fails_on_error=False) else: rc, out, err = (0, "", "") diff --git a/plugins/modules/helm_repository.py b/plugins/modules/helm_repository.py index d98a0202..34213add 100644 --- a/plugins/modules/helm_repository.py +++ b/plugins/modules/helm_repository.py @@ -178,10 +178,9 @@ except ImportError: IMP_YAML_ERR = traceback.format_exc() IMP_YAML = False -from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.basic import missing_required_lib from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( - run_helm, - get_helm_binary, + AnsibleHelmModule, ) from ansible_collections.kubernetes.core.plugins.module_utils.helm_args_common import ( HELM_AUTH_ARG_SPEC, @@ -199,10 +198,10 @@ def get_repository(state, repo_name): # Get repository status -def get_repository_status(module, command, repository_name): - list_command = command + " repo list --output=yaml" +def get_repository_status(module, repository_name): + list_command = module.get_helm_binary() + " repo list --output=yaml" - rc, out, err = run_helm(module, list_command, fails_on_error=False) + rc, out, err = module.run_helm_command(list_command, fails_on_error=False) # no repo => rc=1 and 'no repositories to show' in output if rc == 1 and "no repositories to show" in err: @@ -271,7 +270,7 @@ def argument_spec(): def main(): global module - module = AnsibleModule( + module = AnsibleHelmModule( argument_spec=argument_spec(), required_together=[["repo_username", "repo_password"]], required_if=[("repo_state", "present", ["repo_url"])], @@ -292,9 +291,9 @@ def main(): pass_credentials = module.params.get("pass_credentials") force_update = module.params.get("force_update") - helm_cmd = get_helm_binary(module) + helm_cmd = module.get_helm_binary() - repository_status = get_repository_status(module, helm_cmd, repo_name) + repository_status = get_repository_status(module, repo_name) if repo_state == "absent" and repository_status is not None: helm_cmd = delete_repository(helm_cmd, repo_name) @@ -321,7 +320,7 @@ def main(): elif not changed: module.exit_json(changed=False, repo_name=repo_name, repo_url=repo_url) - rc, out, err = run_helm(module, helm_cmd) + rc, out, err = module.run_helm_command(helm_cmd) if repo_password is not None: helm_cmd = helm_cmd.replace(repo_password, "******") diff --git a/plugins/modules/helm_template.py b/plugins/modules/helm_template.py index 044a248e..e8476dd0 100644 --- a/plugins/modules/helm_template.py +++ b/plugins/modules/helm_template.py @@ -181,10 +181,9 @@ except ImportError: IMP_YAML_ERR = traceback.format_exc() IMP_YAML = False -from ansible.module_utils.basic import AnsibleModule, missing_required_lib +from ansible.module_utils.basic import missing_required_lib from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( - run_helm, - get_helm_binary, + AnsibleHelmModule, ) @@ -249,7 +248,7 @@ def template( def main(): - module = AnsibleModule( + module = AnsibleHelmModule( argument_spec=dict( binary_path=dict(type="path"), chart_ref=dict(type="path", required=True), @@ -287,11 +286,11 @@ def main(): if not IMP_YAML: module.fail_json(msg=missing_required_lib("yaml"), exception=IMP_YAML_ERR) - helm_cmd = get_helm_binary(module) + helm_cmd = module.get_helm_binary() if update_repo_cache: update_cmd = helm_cmd + " repo update" - run_helm(module, update_cmd) + module.run_helm_command(update_cmd) tmpl_cmd = template( helm_cmd, @@ -310,7 +309,7 @@ def main(): ) if not check_mode: - rc, out, err = run_helm(module, tmpl_cmd) + rc, out, err = module.run_helm_command(tmpl_cmd) else: out = err = "" rc = 0 diff --git a/tests/integration/targets/helm/defaults/main.yml b/tests/integration/targets/helm/defaults/main.yml index a29cd260..ffff2e44 100644 --- a/tests/integration/targets/helm/defaults/main.yml +++ b/tests/integration/targets/helm/defaults/main.yml @@ -1,5 +1,5 @@ --- -helm_archive_name: "helm-{{ helm_version }}-{{ ansible_system | lower }}-amd64.tar.gz" +helm_default_archive_name: "helm-{{ helm_version }}-{{ ansible_system | lower }}-amd64.tar.gz" helm_binary: "/tmp/helm/{{ ansible_system | lower }}-amd64/helm" chart_test: "ingress-nginx" @@ -14,6 +14,8 @@ chart_test_values: revisionHistoryLimit: 0 myValue: "changed" +default_kubeconfig_path: "~/.kube/config" + test_namespace: - "helm-diff" - "helm-envvars" @@ -26,4 +28,6 @@ test_namespace: - "helm-local-path-002" - "helm-local-path-003" - "helm-dep" - - "helm-kubeconfig" + - "helm-in-memory-kubeconfig" + - "helm-kubeconfig-with-insecure-skip-tls-verify" + - "helm-kubeconfig-with-ca-cert" diff --git a/tests/integration/targets/helm/tasks/install.yml b/tests/integration/targets/helm/tasks/install.yml index 4b68b873..248925ee 100644 --- a/tests/integration/targets/helm/tasks/install.yml +++ b/tests/integration/targets/helm/tasks/install.yml @@ -6,7 +6,7 @@ - name: Unarchive Helm binary unarchive: - src: 'https://get.helm.sh/{{ helm_archive_name }}' + src: 'https://get.helm.sh/{{ helm_archive_name | default(helm_default_archive_name) }}' dest: /tmp/helm/ remote_src: yes retries: 10 diff --git a/tests/integration/targets/helm/tasks/run_test.yml b/tests/integration/targets/helm/tasks/run_test.yml index 52f723f5..f54d58ec 100644 --- a/tests/integration/targets/helm/tasks/run_test.yml +++ b/tests/integration/targets/helm/tasks/run_test.yml @@ -43,8 +43,8 @@ - name: Test Skip CRDS feature in helm chart install include_tasks: test_crds.yml -- name: Test in-memory kubeconfig - include_tasks: tests_in_memory_kubeconfig.yml +- name: Test helm modules with custom kube config, validate_certs and/or ca_cert + include_tasks: tests_helm_kubeconfig.yml - name: Test helm pull include_tasks: tests_helm_pull.yml diff --git a/tests/integration/targets/helm/tasks/tests_in_memory_kubeconfig.yml b/tests/integration/targets/helm/tasks/tests_helm_auth.yml similarity index 57% rename from tests/integration/targets/helm/tasks/tests_in_memory_kubeconfig.yml rename to tests/integration/targets/helm/tasks/tests_helm_auth.yml index f1bd831e..63b252ff 100644 --- a/tests/integration/targets/helm/tasks/tests_in_memory_kubeconfig.yml +++ b/tests/integration/targets/helm/tasks/tests_helm_auth.yml @@ -1,30 +1,41 @@ --- +- name: create temporary directory + tempfile: + state: directory + suffix: .helm + register: _dir + +- name: Install helm binary + block: + - name: "Install {{ test_helm_version }}" + include_tasks: install.yml + vars: + helm_archive_name: "helm-{{ test_helm_version }}-{{ ansible_system | lower }}-amd64.tar.gz" + + when: test_helm_version is defined + - set_fact: - custom_kubeconfig_path: "~/.kube/customconfig" - default_kubeconfig_path: "~/.kube/config" - helm_in_mem_kubeconf_ns: "{{ test_namespace[11] }}" + saved_kubeconfig_path: "{{ _dir.path }}/config" - block: - name: Copy default kubeconfig copy: remote_src: true src: "{{ default_kubeconfig_path }}" - dest: "{{ custom_kubeconfig_path }}" + dest: "{{ saved_kubeconfig_path }}" - name: Delete default kubeconfig file: path: "{{ default_kubeconfig_path }}" state: absent - - set_fact: - custom_kubeconfig: "{{ lookup('file', custom_kubeconfig_path) | from_yaml }}" - no_log: true - # helm_plugin and helm_plugin_info - name: Install subenv plugin helm_plugin: binary_path: "{{ helm_binary }}" - kubeconfig: "{{ custom_kubeconfig }}" + kubeconfig: "{{ test_kubeconfig | default(omit) }}" + validate_certs: "{{ test_validate_certs | default(omit) }}" + ca_cert: "{{ test_ca_cert | default(omit) }}" state: present plugin_path: https://github.com/hydeenoble/helm-subenv register: plugin @@ -36,7 +47,9 @@ - name: Gather info about all plugin helm_plugin_info: binary_path: "{{ helm_binary }}" - kubeconfig: "{{ custom_kubeconfig }}" + kubeconfig: "{{ test_kubeconfig | default(omit) }}" + validate_certs: "{{ test_validate_certs | default(omit) }}" + ca_cert: "{{ test_ca_cert | default(omit) }}" register: plugin_info - assert: @@ -49,7 +62,9 @@ helm_repository: binary_path: "{{ helm_binary }}" name: test_bitnami - kubeconfig: "{{ custom_kubeconfig }}" + kubeconfig: "{{ test_kubeconfig | default(omit) }}" + validate_certs: "{{ test_validate_certs | default(omit) }}" + ca_cert: "{{ test_ca_cert | default(omit) }}" repo_url: https://charts.bitnami.com/bitnami register: repository @@ -63,9 +78,11 @@ binary_path: "{{ helm_binary }}" name: rabbitmq chart_ref: test_bitnami/rabbitmq - namespace: "{{ helm_in_mem_kubeconf_ns }}" + namespace: "{{ helm_namespace }}" update_repo_cache: true - kubeconfig: "{{ custom_kubeconfig }}" + kubeconfig: "{{ test_kubeconfig | default(omit) }}" + validate_certs: "{{ test_validate_certs | default(omit) }}" + ca_cert: "{{ test_ca_cert | default(omit) }}" create_namespace: true register: deploy @@ -77,9 +94,11 @@ - name: Get chart content helm_info: binary_path: "{{ helm_binary }}" - kubeconfig: "{{ custom_kubeconfig }}" + kubeconfig: "{{ test_kubeconfig | default(omit) }}" + validate_certs: "{{ test_validate_certs | default(omit) }}" + ca_cert: "{{ test_ca_cert | default(omit) }}" name: "rabbitmq" - namespace: "{{ helm_in_mem_kubeconf_ns }}" + namespace: "{{ helm_namespace }}" register: chart_info - name: Assert chart was successfully deployed @@ -93,8 +112,10 @@ helm: binary_path: "{{ helm_binary }}" name: rabbitmq - namespace: "{{ helm_in_mem_kubeconf_ns }}" - kubeconfig: "{{ custom_kubeconfig }}" + namespace: "{{ helm_namespace }}" + kubeconfig: "{{ test_kubeconfig | default(omit) }}" + validate_certs: "{{ test_validate_certs | default(omit) }}" + ca_cert: "{{ test_ca_cert | default(omit) }}" state: absent register: remove_chart @@ -106,9 +127,11 @@ - name: Get chart content helm_info: binary_path: "{{ helm_binary }}" - kubeconfig: "{{ custom_kubeconfig }}" + kubeconfig: "{{ test_kubeconfig | default(omit) }}" + validate_certs: "{{ test_validate_certs | default(omit) }}" + ca_cert: "{{ test_ca_cert | default(omit) }}" name: "rabbitmq" - namespace: "{{ helm_in_mem_kubeconf_ns }}" + namespace: "{{ helm_namespace }}" register: chart_info - name: Assert chart was successfully deployed @@ -120,7 +143,9 @@ helm_repository: binary_path: "{{ helm_binary }}" name: test_bitnami - kubeconfig: "{{ custom_kubeconfig }}" + kubeconfig: "{{ test_kubeconfig | default(omit) }}" + validate_certs: "{{ test_validate_certs | default(omit) }}" + ca_cert: "{{ test_ca_cert | default(omit) }}" state: absent register: remove @@ -133,16 +158,23 @@ - name: Return kubeconfig copy: remote_src: true - src: "{{ custom_kubeconfig_path }}" + src: "{{ saved_kubeconfig_path }}" dest: "{{ default_kubeconfig_path }}" ignore_errors: true - - name: Delete custom config + - name: Delete temporary directory file: - path: "{{ custom_kubeconfig_path }}" + path: "{{ _dir.path }}" state: absent ignore_errors: true + - name: Delete temporary directory for helm install + file: + path: "{{ _helm_install.path }}" + state: absent + ignore_errors: true + when: _helm_install is defined + - name: Remove subenv plugin helm_plugin: binary_path: "{{ helm_binary }}" @@ -153,7 +185,7 @@ - name: Delete namespace k8s: kind: Namespace - name: "{{ helm_in_mem_kubeconf_ns }}" + name: "{{ helm_namespace }}" ignore_errors: true - name: Delete helm repository diff --git a/tests/integration/targets/helm/tasks/tests_helm_diff.yml b/tests/integration/targets/helm/tasks/tests_helm_diff.yml index 9605a3b7..3a3c91f7 100644 --- a/tests/integration/targets/helm/tasks/tests_helm_diff.yml +++ b/tests/integration/targets/helm/tasks/tests_helm_diff.yml @@ -2,6 +2,7 @@ - name: Test helm diff functionality vars: test_chart_ref: "/tmp/test-chart" + redis_chart_version: '17.0.5' block: - set_fact: @@ -182,7 +183,7 @@ chart_ref: redis namespace: "{{ helm_namespace }}" name: redis-chart - chart_version: '16.0.0' + chart_version: "{{ redis_chart_version }}" release_values: "{{ redis_chart_values }}" - name: Upgrade Redis chart @@ -192,7 +193,7 @@ chart_ref: redis namespace: "{{ helm_namespace }}" name: redis-chart - chart_version: '16.0.0' + chart_version: "{{ redis_chart_version }}" release_values: "{{ redis_chart_values }}" check_mode: yes register: redis_upgrade @@ -226,7 +227,7 @@ chart_ref: redis namespace: "{{ helm_namespace }}" name: redis-chart - chart_version: '16.0.0' + chart_version: "{{ redis_chart_version }}" release_values: "{{ redis_chart_values }}" check_mode: yes register: redis_upgrade_2 diff --git a/tests/integration/targets/helm/tasks/tests_helm_kubeconfig.yml b/tests/integration/targets/helm/tasks/tests_helm_kubeconfig.yml new file mode 100644 index 00000000..5da2ce44 --- /dev/null +++ b/tests/integration/targets/helm/tasks/tests_helm_kubeconfig.yml @@ -0,0 +1,22 @@ +--- +- name: Test helm with in-memory kubeconfig + include_tasks: "tests_kubeconfig/from_in_memory_kubeconfig.yml" + +- name: Test helm with custom kubeconfig and validate_certs=false + include_tasks: "tests_kubeconfig/from_kubeconfig_with_validate_certs.yml" + loop_control: + loop_var: test_helm_version + with_items: + - "v3.10.3" + - "v3.8.2" + +- name: Test helm with custom kubeconfig and ca_cert + include_tasks: "tests_kubeconfig/from_kubeconfig_with_cacert.yml" + loop_control: + loop_var: test_helm_version + with_items: + - "v3.5.1" + - "v3.4.2" + +- name: install default helm archive version + include_tasks: install.yml diff --git a/tests/integration/targets/helm/tasks/tests_kubeconfig/from_in_memory_kubeconfig.yml b/tests/integration/targets/helm/tasks/tests_kubeconfig/from_in_memory_kubeconfig.yml new file mode 100644 index 00000000..5b242145 --- /dev/null +++ b/tests/integration/targets/helm/tasks/tests_kubeconfig/from_in_memory_kubeconfig.yml @@ -0,0 +1,9 @@ +--- +- set_fact: + custom_config: "{{ lookup('file', default_kubeconfig_path | expanduser) | from_yaml }}" + +- name: Test helm modules using in-memory kubeconfig + include_tasks: "../tests_helm_auth.yml" + vars: + test_kubeconfig: "{{ custom_config }}" + helm_namespace: "{{ test_namespace[11] }}" diff --git a/tests/integration/targets/helm/tasks/tests_kubeconfig/from_kubeconfig_with_cacert.yml b/tests/integration/targets/helm/tasks/tests_kubeconfig/from_kubeconfig_with_cacert.yml new file mode 100644 index 00000000..9dddfb16 --- /dev/null +++ b/tests/integration/targets/helm/tasks/tests_kubeconfig/from_kubeconfig_with_cacert.yml @@ -0,0 +1,76 @@ +--- +- set_fact: + content: "{{ lookup('file', default_kubeconfig_path) | from_yaml }}" + custom_content: {} + clusters: [] + +- set_fact: + custom_content: "{{ custom_content | combine({item.key: item.value}) }}" + when: "{{ item.key not in ['clusters'] }}" + with_dict: "{{ content }}" + +- set_fact: + clusters: "{{ clusters + [item | combine({'cluster': {'certificate-authority-data': omit}}, recursive=true)] }}" + with_items: "{{ content.clusters }}" + +- set_fact: + custom_content: "{{ custom_content | combine({'clusters': clusters}) }}" + +- name: create temporary file for ca_cert + tempfile: + suffix: .cacert + register: ca_file + +- name: copy content into certificate file + copy: + content: "{{ content.clusters.0.cluster['certificate-authority-data'] | b64decode }}" + dest: "{{ ca_file.path }}" + +- name: create temporary file to save config in + tempfile: + suffix: .config + register: tfile + +- name: create custom config + copy: + content: "{{ custom_content | to_yaml }}" + dest: "{{ tfile.path }}" + +- block: + - name: Install Redis chart without ca_cert (should fail) + helm: + binary_path: "{{ helm_binary }}" + chart_repo_url: https://charts.bitnami.com/bitnami + chart_ref: redis + namespace: "{{ helm_namespace }}" + create_namespace: true + name: redis-chart + chart_version: '17.0.5' + release_values: + architecture: standalone + release_state: present + kubeconfig: "{{ tfile.path }}" + ignore_errors: true + register: _install + + - name: assert task failed + assert: + that: + - _install is failed + - '"Error: Kubernetes cluster unreachable" in _install.msg' + + - name: Test helm modules using in-memory kubeconfig + include_tasks: "../tests_helm_auth.yml" + vars: + test_kubeconfig: "{{ tfile.path }}" + test_ca_cert: "{{ ca_file.path }}" + + vars: + helm_namespace: "{{ test_namespace[13] }}" + + always: + - name: Delete temporary file + file: + state: absent + path: "{{ tfile.path }}" + ignore_errors: true diff --git a/tests/integration/targets/helm/tasks/tests_kubeconfig/from_kubeconfig_with_validate_certs.yml b/tests/integration/targets/helm/tasks/tests_kubeconfig/from_kubeconfig_with_validate_certs.yml new file mode 100644 index 00000000..0a15a42d --- /dev/null +++ b/tests/integration/targets/helm/tasks/tests_kubeconfig/from_kubeconfig_with_validate_certs.yml @@ -0,0 +1,67 @@ +--- +- set_fact: + content: "{{ lookup('file', default_kubeconfig_path) | from_yaml }}" + custom_content: {} + clusters: [] + +- set_fact: + custom_content: "{{ custom_content | combine({item.key: item.value}) }}" + when: "{{ item.key not in ['clusters'] }}" + with_dict: "{{ content }}" + +- set_fact: + clusters: "{{ clusters + [item | combine({'cluster': {'certificate-authority-data': omit}}, recursive=true)] }}" + with_items: "{{ content.clusters }}" + +- set_fact: + custom_content: "{{ custom_content | combine({'clusters': clusters}) }}" + +- name: create temporary file to save config in + tempfile: + suffix: .config + register: tfile + +- name: create custom config + copy: + content: "{{ custom_content | to_yaml }}" + dest: "{{ tfile.path }}" + +- block: + - name: Install Redis chart without validate_certs=false (should fail) + helm: + binary_path: "{{ helm_binary }}" + chart_repo_url: https://charts.bitnami.com/bitnami + chart_ref: redis + namespace: "{{ helm_namespace }}" + create_namespace: true + name: redis-chart + chart_version: '17.0.5' + release_values: + architecture: standalone + release_state: present + kubeconfig: "{{ tfile.path }}" + validate_certs: true + ignore_errors: true + register: _install + + - name: assert task failed + assert: + that: + - _install is failed + - '"Error: Kubernetes cluster unreachable" in _install.msg' + + - name: Test helm modules using in-memory kubeconfig + include_tasks: "../tests_helm_auth.yml" + vars: + test_kubeconfig: "{{ tfile.path }}" + test_validate_certs: false + + vars: + helm_namespace: "{{ test_namespace[12] }}" + + always: + - name: Delete temporary file + file: + state: absent + path: "{{ tfile.path }}" + ignore_errors: true diff --git a/tests/unit/module_utils/test_helm.py b/tests/unit/module_utils/test_helm.py index 39ac33d3..bb8843e5 100644 --- a/tests/unit/module_utils/test_helm.py +++ b/tests/unit/module_utils/test_helm.py @@ -9,53 +9,43 @@ __metaclass__ = type import os.path import yaml import tempfile -import json +import pytest from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( - run_helm, + AnsibleHelmModule, write_temp_kubeconfig, ) +from unittest.mock import MagicMock +import random +import string -class MockedModule: - def __init__(self): +@pytest.fixture() +def ansible_helm_module(): + module = MagicMock() + module.params = { + "api_key": None, + "ca_cert": None, + "host": None, + "kube_context": None, + "kubeconfig": None, + "release_namespace": None, + "validate_certs": None, + } + module.fail_json = MagicMock() + module.fail_json.side_effect = SystemExit(1) + module.run_command = MagicMock() - self.params = { - "api_key": None, - "ca_cert": None, - "host": None, - "kube_context": None, - "kubeconfig": None, - "release_namespace": None, - "validate_certs": None, - } + helm_module = AnsibleHelmModule(module=module) + helm_module.get_helm_binary = MagicMock() + helm_module.get_helm_binary.return_value = "some/path/to/helm/executable" - self.r = {} - self.files_to_delete = [] - - def run_command(self, command, environ_update=None): - self.r = {"command": command, "environ_update": environ_update} - return 0, "", "" - - def add_cleanup_file(self, file_path): - self.files_to_delete.append(file_path) - - def do_cleanup_files(self): - for file in self.files_to_delete: - try: - os.remove(file) - except Exception: - pass + return helm_module def test_write_temp_kubeconfig_server_only(): - file_name = write_temp_kubeconfig("ff") - try: - with open(file_name, "r") as fd: - content = yaml.safe_load(fd) - finally: - os.remove(file_name) + content = write_temp_kubeconfig("ff") assert content == { "apiVersion": "v1", @@ -69,12 +59,7 @@ def test_write_temp_kubeconfig_server_only(): def test_write_temp_kubeconfig_server_inscure_certs(): - file_name = write_temp_kubeconfig("ff", False, "my-certificate") - try: - with open(file_name, "r") as fd: - content = yaml.safe_load(fd) - finally: - os.remove(file_name) + content = write_temp_kubeconfig("ff", False, "my-certificate") assert content["clusters"][0]["cluster"]["insecure-skip-tls-verify"] is True assert ( @@ -82,87 +67,351 @@ def test_write_temp_kubeconfig_server_inscure_certs(): ) -def test_run_helm_naked(): - module = MockedModule() - run_helm(module, "helm foo") - - assert module.r["command"] == "helm foo" - assert module.r["environ_update"] == {} - - -def test_run_helm_with_params(): - module = MockedModule() - module.params = { - "api_key": "my-api-key", - "ca_cert": "my-ca-cert", - "host": "some-host", - "context": "my-context", - "release_namespace": "a-release-namespace", - "validate_certs": False, - } - - run_helm(module, "helm foo") - - assert module.r["command"] == "helm foo" - assert module.r["environ_update"]["HELM_KUBEAPISERVER"] == "some-host" - assert module.r["environ_update"]["HELM_KUBECONTEXT"] == "my-context" - assert module.r["environ_update"]["HELM_KUBETOKEN"] == "my-api-key" - assert module.r["environ_update"]["HELM_NAMESPACE"] == "a-release-namespace" - assert module.r["environ_update"]["KUBECONFIG"] - assert os.path.exists(module.r["environ_update"]["KUBECONFIG"]) - module.do_cleanup_files() - - -def test_run_helm_with_kubeconfig(): - - custom_config = { +def test_write_temp_kubeconfig_with_kubeconfig(): + kubeconfig = { "apiVersion": "v1", + "kind": "Config", + "clusters": [ + {"cluster": {"server": "myfirstserver"}, "name": "cluster-01"}, + {"cluster": {"server": "mysecondserver"}, "name": "cluster-02"}, + ], + "contexts": [{"context": {"cluster": "cluster-01"}, "name": "test-context"}], + "current-context": "test-context", + } + content = write_temp_kubeconfig( + server="mythirdserver", + validate_certs=False, + ca_cert="some-ca-cert-for-test", + kubeconfig=kubeconfig, + ) + + expected = { + "apiVersion": "v1", + "kind": "Config", "clusters": [ { "cluster": { - "certificate-authority-data": "LS0tLS1CRUdJTiBDRV", - "server": "https://api.cluster.testing:6443", + "server": "mythirdserver", + "insecure-skip-tls-verify": True, + "certificate-authority": "some-ca-cert-for-test", }, - "name": "api-cluster-testing:6443", - } - ], - "contexts": [ + "name": "cluster-01", + }, { - "context": { - "cluster": "api-cluster-testing:6443", - "namespace": "default", - "user": "kubeadmin", + "cluster": { + "server": "mythirdserver", + "insecure-skip-tls-verify": True, + "certificate-authority": "some-ca-cert-for-test", }, - "name": "context-1", - } - ], - "current-context": "context-1", - "kind": "Config", - "users": [ - { - "name": "developer", - "user": {"token": "sha256~jbIvVieBC_8W6Pb-iH5vqC_BvvPHIxQMxUPLDnYvHYM"}, - } + "name": "cluster-02", + }, ], + "contexts": [{"context": {"cluster": "cluster-01"}, "name": "test-context"}], + "current-context": "test-context", } - # kubeconfig defined as path - _fd, tmpfile_name = tempfile.mkstemp() - with os.fdopen(_fd, "w") as fp: - yaml.dump(custom_config, fp) + assert content == expected - k1_module = MockedModule() - k1_module.params = {"kubeconfig": tmpfile_name} - run_helm(k1_module, "helm foo") - assert k1_module.r["environ_update"] == {"KUBECONFIG": tmpfile_name} - os.remove(tmpfile_name) - # kubeconfig defined as string - k2_module = MockedModule() - k2_module.params = {"kubeconfig": custom_config} - run_helm(k2_module, "helm foo") +def test_module_get_helm_binary_from_params(): - assert os.path.exists(k2_module.r["environ_update"]["KUBECONFIG"]) - with open(k2_module.r["environ_update"]["KUBECONFIG"]) as f: - assert json.loads(f.read()) == custom_config - k2_module.do_cleanup_files() + helm_binary_path = MagicMock() + helm_sys_binary_path = MagicMock() + + module = MagicMock() + module.params = { + "binary_path": helm_binary_path, + } + module.get_bin_path.return_value = helm_sys_binary_path + + helm_module = AnsibleHelmModule(module=module) + assert helm_module.get_helm_binary() == helm_binary_path + + +def test_module_get_helm_binary_from_system(): + + helm_sys_binary_path = MagicMock() + module = MagicMock() + module.params = {} + module.get_bin_path.return_value = helm_sys_binary_path + + helm_module = AnsibleHelmModule(module=module) + assert helm_module.get_helm_binary() == helm_sys_binary_path + + +def test_module_get_helm_plugin_list(ansible_helm_module): + + ansible_helm_module.run_helm_command = MagicMock() + ansible_helm_module.run_helm_command.return_value = (0, "output", "error") + + rc, out, err, command = ansible_helm_module.get_helm_plugin_list() + + assert (rc, out, err) == (0, "output", "error") + assert command == "some/path/to/helm/executable plugin list" + + ansible_helm_module.get_helm_binary.assert_called_once() + ansible_helm_module.run_helm_command.assert_called_once_with( + "some/path/to/helm/executable plugin list" + ) + + +def test_module_get_helm_plugin_list_failure(ansible_helm_module): + + ansible_helm_module.run_helm_command = MagicMock() + ansible_helm_module.run_helm_command.return_value = (-1, "output", "error") + + with pytest.raises(SystemExit): + ansible_helm_module.get_helm_plugin_list() + + ansible_helm_module.fail_json.assert_called_once_with( + msg="Failed to get Helm plugin info", + command="some/path/to/helm/executable plugin list", + stdout="output", + stderr="error", + rc=-1, + ) + + +@pytest.mark.parametrize("no_values", [True, False]) +@pytest.mark.parametrize("get_all", [True, False]) +def test_module_get_values(ansible_helm_module, no_values, get_all): + + expected = {"test": "units"} + output = "---\ntest: units\n" + + if no_values: + expected = {} + output = "null" + + ansible_helm_module.run_helm_command = MagicMock() + ansible_helm_module.run_helm_command.return_value = (0, output, "error") + + release_name = "".join( + random.choice(string.ascii_letters + string.digits) for x in range(10) + ) + result = ansible_helm_module.get_values(release_name, get_all=get_all) + + ansible_helm_module.get_helm_binary.assert_called_once() + command = f"some/path/to/helm/executable get values --output=yaml {release_name}" + if get_all: + command += " -a" + ansible_helm_module.run_helm_command.assert_called_once_with(command) + assert result == expected + + +@pytest.mark.parametrize( + "output,expected", + [ + ( + 'version.BuildInfo{Version:"v3.10.3", GitCommit:7870ab3ed4135f136eec, GoVersion:"go1.18.9"}', + "3.10.3", + ), + ('Client: &version.Version{SemVer:"v3.12.3", ', "3.12.3"), + ('Client: &version.Version{SemVer:"v3.12.3"', None), + ], +) +def test_module_get_helm_version(ansible_helm_module, output, expected): + + ansible_helm_module.run_command = MagicMock() + ansible_helm_module.run_command.return_value = (0, output, "error") + + result = ansible_helm_module.get_helm_version() + + ansible_helm_module.get_helm_binary.assert_called_once() + command = "some/path/to/helm/executable version" + ansible_helm_module.run_command.assert_called_once_with(command) + assert result == expected + + +def test_module_run_helm_command(ansible_helm_module): + + error = "".join( + random.choice(string.ascii_letters + string.digits) for x in range(10) + ) + output = "".join( + random.choice(string.ascii_letters + string.digits) for x in range(10) + ) + + ansible_helm_module.run_command.return_value = (0, output, error) + + ansible_helm_module._prepare_helm_environment = MagicMock() + env_update = {x: random.choice(string.ascii_letters) for x in range(10)} + ansible_helm_module._prepare_helm_environment.return_value = env_update + + command = "".join( + random.choice(string.ascii_letters + string.digits) for x in range(10) + ) + rc, out, err = ansible_helm_module.run_helm_command(command) + + assert (rc, out, err) == (0, output, error) + + ansible_helm_module.run_command.assert_called_once_with( + command, environ_update=env_update + ) + + +@pytest.mark.parametrize("fails_on_error", [True, False]) +def test_module_run_helm_command_failure(ansible_helm_module, fails_on_error): + + error = "".join( + random.choice(string.ascii_letters + string.digits) for x in range(10) + ) + output = "".join( + random.choice(string.ascii_letters + string.digits) for x in range(10) + ) + return_code = random.randint(1, 10) + ansible_helm_module.run_command.return_value = (return_code, output, error) + + ansible_helm_module._prepare_environment = MagicMock() + + command = "".join( + random.choice(string.ascii_letters + string.digits) for x in range(10) + ) + + if fails_on_error: + with pytest.raises(SystemExit): + rc, out, err = ansible_helm_module.run_helm_command( + command, fails_on_error=fails_on_error + ) + ansible_helm_module.fail_json.assert_called_with( + msg="Failure when executing Helm command. Exited {0}.\nstdout: {1}\nstderr: {2}".format( + return_code, output, error + ), + stdout=output, + stderr=error, + command=command, + ) + else: + rc, out, err = ansible_helm_module.run_helm_command( + command, fails_on_error=fails_on_error + ) + assert (rc, out, err) == (return_code, output, error) + + +@pytest.mark.parametrize( + "params,env_update,kubeconfig", + [ + ( + { + "api_key": "my-api-key", + "host": "some-host", + "context": "my-context", + "release_namespace": "a-release-namespace", + }, + { + "HELM_KUBEAPISERVER": "some-host", + "HELM_KUBECONTEXT": "my-context", + "HELM_KUBETOKEN": "my-api-key", + "HELM_NAMESPACE": "a-release-namespace", + }, + False, + ), + ({"kubeconfig": {"kube": "config"}}, {}, True), + ({"kubeconfig": "path_to_a_config_file"}, {}, True), + ], +) +def test_module_prepare_helm_environment(params, env_update, kubeconfig): + + module = MagicMock() + module.params = params + + helm_module = AnsibleHelmModule(module=module) + + p_kubeconfig = params.get("kubeconfig") + tmpfile_name = None + if isinstance(p_kubeconfig, str): + _fd, tmpfile_name = tempfile.mkstemp() + with os.fdopen(_fd, "w") as fp: + yaml.dump({"some_custom": "kube_config"}, fp) + params["kubeconfig"] = tmpfile_name + + result = helm_module._prepare_helm_environment() + + kubeconfig_path = result.pop("KUBECONFIG", None) + + assert env_update == result + + if kubeconfig: + assert os.path.exists(kubeconfig_path) + if not tmpfile_name: + module.add_cleanup_file.assert_called_with(kubeconfig_path) + else: + assert kubeconfig_path is None + + if tmpfile_name: + os.remove(tmpfile_name) + + +@pytest.mark.parametrize( + "helm_version, is_env_var_set", + [ + ("3.10.1", True), + ("3.10.0", True), + ("3.5.0", False), + ("3.8.0", False), + ("3.9.35", False), + ], +) +def test_module_prepare_helm_environment_with_validate_certs( + helm_version, is_env_var_set +): + + module = MagicMock() + module.params = {"validate_certs": False} + + helm_module = AnsibleHelmModule(module=module) + helm_module.get_helm_version = MagicMock() + helm_module.get_helm_version.return_value = helm_version + + result = helm_module._prepare_helm_environment() + + if is_env_var_set: + assert result == {"HELM_KUBEINSECURE_SKIP_TLS_VERIFY": "true"} + else: + assert list(result.keys()) == ["KUBECONFIG"] + kubeconfig_path = result["KUBECONFIG"] + assert os.path.exists(kubeconfig_path) + + with open(kubeconfig_path) as fd: + content = yaml.safe_load(fd) + assert content["clusters"][0]["cluster"]["insecure-skip-tls-verify"] is True + os.remove(kubeconfig_path) + + +@pytest.mark.parametrize( + "helm_version, is_env_var_set", + [ + ("3.10.0", True), + ("3.5.0", True), + ("3.4.9", False), + ], +) +def test_module_prepare_helm_environment_with_ca_cert(helm_version, is_env_var_set): + + ca_cert = "".join( + random.choice(string.ascii_letters + string.digits) for i in range(50) + ) + module = MagicMock() + module.params = {"ca_cert": ca_cert} + + helm_module = AnsibleHelmModule(module=module) + helm_module.get_helm_version = MagicMock() + helm_module.get_helm_version.return_value = helm_version + + result = helm_module._prepare_helm_environment() + + if is_env_var_set: + assert list(result.keys()) == ["HELM_KUBECAFILE"] + assert result["HELM_KUBECAFILE"] == ca_cert + else: + assert list(result.keys()) == ["KUBECONFIG"] + kubeconfig_path = result["KUBECONFIG"] + assert os.path.exists(kubeconfig_path) + + with open(kubeconfig_path) as fd: + content = yaml.safe_load(fd) + import json + + print(json.dumps(content, indent=2)) + assert content["clusters"][0]["cluster"]["certificate-authority"] == ca_cert + os.remove(kubeconfig_path)