diff --git a/changelogs/fragments/20260107-add-idempodency-for-helm-pull.yaml b/changelogs/fragments/20260107-add-idempodency-for-helm-pull.yaml new file mode 100644 index 00000000..37058d51 --- /dev/null +++ b/changelogs/fragments/20260107-add-idempodency-for-helm-pull.yaml @@ -0,0 +1,2 @@ +bugfixes: + - Add idempotency for ``helm_pull`` module (https://github.com/ansible-collections/kubernetes.core/pull/1055). diff --git a/docs/kubernetes.core.helm_pull_module.rst b/docs/kubernetes.core.helm_pull_module.rst index c89978e7..faef8a7a 100644 --- a/docs/kubernetes.core.helm_pull_module.rst +++ b/docs/kubernetes.core.helm_pull_module.rst @@ -174,6 +174,28 @@ Parameters
location to write the chart.
+ + +
+ force + +
+ boolean +
+
added in 6.3.0
+ + + + + +
Force download of the chart even if it already exists in the destination directory.
+
By default, the module will skip downloading if the chart with the same version already exists for idempotency.
+
When used with O(untar_chart=true), will remove any existing chart directory before extracting.
+ +
@@ -397,6 +419,23 @@ Examples username: myuser password: mypassword123 + - name: Download Chart (force re-download even if exists) + kubernetes.core.helm_pull: + chart_ref: redis + repo_url: https://charts.bitnami.com/bitnami + chart_version: '17.0.0' + destination: /path/to/chart + force: yes + + - name: Download and untar chart (force re-extraction even if directory exists) + kubernetes.core.helm_pull: + chart_ref: redis + repo_url: https://charts.bitnami.com/bitnami + chart_version: '17.0.0' + destination: /path/to/chart + untar_chart: yes + force: yes + Return Values @@ -428,6 +467,23 @@ Common return values are documented `here helm pull --repo test ... + + +
+ msg + +
+ string +
+ + when chart already exists + +
A message indicating the result of the operation.
+
+
Sample:
+
Chart redis version 17.0.0 already exists in destination directory
+ +
diff --git a/plugins/modules/helm_pull.py b/plugins/modules/helm_pull.py index 3c374ac6..123ea31b 100644 --- a/plugins/modules/helm_pull.py +++ b/plugins/modules/helm_pull.py @@ -89,6 +89,14 @@ options: - if set to true, will untar the chart after downloading it. type: bool default: False + force: + description: + - Force download of the chart even if it already exists in the destination directory. + - By default, the module will skip downloading if the chart with the same version already exists for idempotency. + - When used with O(untar_chart=true), will remove any existing chart directory before extracting. + type: bool + default: False + version_added: 6.3.0 destination: description: - location to write the chart. @@ -152,6 +160,23 @@ EXAMPLES = r""" destination: /path/to/chart username: myuser password: mypassword123 + +- name: Download Chart (force re-download even if exists) + kubernetes.core.helm_pull: + chart_ref: redis + repo_url: https://charts.bitnami.com/bitnami + chart_version: '17.0.0' + destination: /path/to/chart + force: yes + +- name: Download and untar chart (force re-extraction even if directory exists) + kubernetes.core.helm_pull: + chart_ref: redis + repo_url: https://charts.bitnami.com/bitnami + chart_version: '17.0.0' + destination: /path/to/chart + untar_chart: yes + force: yes """ RETURN = r""" @@ -170,6 +195,11 @@ command: description: Full `helm pull` command built by this module, in case you want to re-run the command outside the module or debug a problem. returned: always sample: helm pull --repo test ... +msg: + type: str + description: A message indicating the result of the operation. + returned: when chart already exists + sample: Chart redis version 17.0.0 already exists in destination directory rc: type: int description: Helm pull command return code @@ -177,6 +207,18 @@ rc: sample: 1 """ +import os +import shutil +import tarfile +import uuid + +try: + import yaml + + HAS_YAML = True +except ImportError: + HAS_YAML = False + from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( AnsibleHelmModule, ) @@ -185,6 +227,115 @@ from ansible_collections.kubernetes.core.plugins.module_utils.version import ( ) +def extract_chart_name(chart_ref): + """ + Extract chart name from chart reference. + + Args: + chart_ref (str): Chart reference (name, URL, or OCI reference) + + Returns: + str: Extracted chart name + """ + chart_name = chart_ref.split("/")[-1] + # Remove any query parameters or fragments from URL-based refs + if "?" in chart_name: + chart_name = chart_name.split("?")[0] + if "#" in chart_name: + chart_name = chart_name.split("#")[0] + # Remove .tgz extension if present + if chart_name.endswith(".tgz"): + chart_name = chart_name[:-4] + return chart_name + + +def chart_exists(destination, chart_ref, chart_version, untar_chart): + """ + Check if the chart already exists in the destination directory. + + For untarred charts: check if directory exists with Chart.yaml matching version + For tarred charts: check if .tgz file exists and contains matching version + + Args: + destination (str): Destination directory path + chart_ref (str): Chart reference (name or URL) + chart_version (str): Chart version to check for + untar_chart (bool): Whether to check for untarred or tarred chart + + Returns: + bool: True if chart with matching version exists, False otherwise + """ + # YAML is required for version checking + if not HAS_YAML: + return False + + # Without version, we can't reliably check + if not chart_version: + return False + + # Extract chart name from chart_ref using shared helper + chart_name = extract_chart_name(chart_ref) + + if untar_chart: + # Check for extracted directory + chart_dir = os.path.join(destination, chart_name) + chart_yaml_path = os.path.join(chart_dir, "Chart.yaml") + + if os.path.isdir(chart_dir) and os.path.isfile(chart_yaml_path): + try: + with open(chart_yaml_path, "r", encoding="utf-8") as chart_file: + chart_metadata = yaml.safe_load(chart_file) + # Ensure chart_metadata is a dict and has a version that matches + if ( + chart_metadata + and isinstance(chart_metadata, dict) + and chart_metadata.get("version") == chart_version + and chart_metadata.get("name") == chart_name + ): + return True + except (yaml.YAMLError, IOError, OSError, TypeError): + # If we can't read or parse the file, treat as non-existent + pass + else: + # Check for .tgz file + chart_file = os.path.join(destination, f"{chart_name}-{chart_version}.tgz") + + if os.path.isfile(chart_file): + try: + # Verify it's a valid tarball with matching version + with tarfile.open(chart_file, "r:gz") as tar: + # Try to extract Chart.yaml to verify version + # Look for Chart.yaml at the expected path: /Chart.yaml + expected_chart_yaml = f"{chart_name}/Chart.yaml" + try: + member = tar.getmember(expected_chart_yaml) + chart_yaml_file = tar.extractfile(member) + if chart_yaml_file: + try: + chart_metadata = yaml.safe_load(chart_yaml_file) + # Ensure chart_metadata is a dict and has a version that matches + if ( + chart_metadata + and isinstance(chart_metadata, dict) + and chart_metadata.get("version") == chart_version + and chart_metadata.get("name") == chart_name + ): + return True + except (yaml.YAMLError, TypeError): + # If we can't parse the YAML, treat as non-existent + pass + finally: + chart_yaml_file.close() + except KeyError: + # Chart.yaml not found at expected path + pass + except (tarfile.TarError, yaml.YAMLError, IOError, OSError, TypeError): + # If we can't read or parse the tarball, treat as non-existent + pass + + return False + + def main(): argspec = dict( chart_ref=dict(type="str", required=True), @@ -203,6 +354,7 @@ def main(): ), chart_devel=dict(type="bool"), untar_chart=dict(type="bool", default=False), + force=dict(type="bool", default=False), destination=dict(type="path", required=True), chart_ca_cert=dict(type="path"), chart_ssl_cert_file=dict(type="path"), @@ -283,8 +435,72 @@ def main(): module.params.get("chart_ref"), " ".join(helm_pull_opts), ) + + # Check if chart already exists (idempotency) + if module.params.get("chart_version") and not module.params.get("force"): + chart_exists_locally = chart_exists( + module.params.get("destination"), + module.params.get("chart_ref"), + module.params.get("chart_version"), + module.params.get("untar_chart"), + ) + + if chart_exists_locally: + module.exit_json( + failed=False, + changed=False, + msg="Chart {0} version {1} already exists in destination directory".format( + module.params.get("chart_ref"), module.params.get("chart_version") + ), + command="", + stdout="", + stderr="", + rc=0, + ) + + # When both untar_chart and force are enabled, we need to remove the existing chart directory + # BEFORE running helm pull to prevent helm's "directory already exists" error. + # We do this by: + # 1. Renaming the existing directory to a temporary name (if it exists) + # 2. Running helm pull + # 3. On success: remove the temporary directory + # 4. On failure: restore the temporary directory and report the error + chart_dir_renamed = False + chart_dir = None + chart_dir_backup = None + + if module.params.get("untar_chart") and module.params.get("force"): + chart_name = extract_chart_name(module.params.get("chart_ref")) + chart_dir = os.path.join(module.params.get("destination"), chart_name) + + # Check if directory exists and contains a Chart.yaml (to be safe) + if os.path.isdir(chart_dir): + chart_yaml_path = os.path.join(chart_dir, "Chart.yaml") + # Only rename if it looks like a Helm chart directory (have Chart.yaml) + if os.path.isfile(chart_yaml_path): + if not module.check_mode: + # Rename to temporary backup name using uuid for uniqueness + backup_suffix = uuid.uuid4().hex[:8] + chart_dir_backup = os.path.join( + module.params.get("destination"), + f".{chart_name}_backup_{backup_suffix}", + ) + os.rename(chart_dir, chart_dir_backup) + chart_dir_renamed = True + if not module.check_mode: rc, out, err = module.run_helm_command(helm_cmd_common, fails_on_error=False) + + # Handle cleanup/restore based on helm command result + if chart_dir_renamed: + if rc == 0: + # Success: remove the backup directory + if os.path.isdir(chart_dir_backup): + shutil.rmtree(chart_dir_backup) + else: + # Failure: restore the backup directory + if os.path.isdir(chart_dir_backup) and not os.path.exists(chart_dir): + os.rename(chart_dir_backup, chart_dir) else: rc, out, err = (0, "", "") diff --git a/tests/integration/targets/helm_pull/tasks/main.yml b/tests/integration/targets/helm_pull/tasks/main.yml index 0abbe507..68697bc2 100644 --- a/tests/integration/targets/helm_pull/tasks/main.yml +++ b/tests/integration/targets/helm_pull/tasks/main.yml @@ -221,6 +221,101 @@ - _chart.stat.exists - _chart.stat.isdir + # Test idempotency with tarred chart + - name: Download chart with version (first time) + helm_pull: + binary_path: "{{ helm_path }}" + chart_ref: "oci://registry-1.docker.io/bitnamicharts/redis" + destination: "{{ destination }}" + chart_version: "24.1.0" + register: _result_first + + - name: Download chart with version (second time - should be idempotent) + helm_pull: + binary_path: "{{ helm_path }}" + chart_ref: "oci://registry-1.docker.io/bitnamicharts/redis" + destination: "{{ destination }}" + chart_version: "24.1.0" + register: _result_second + + - name: Validate idempotency for tarred chart + assert: + that: + - _result_first is changed + - _result_second is not changed + + # Test force parameter with tarred chart + - name: Download chart with force=true (should always download) + helm_pull: + binary_path: "{{ helm_path }}" + chart_ref: "oci://registry-1.docker.io/bitnamicharts/redis" + destination: "{{ destination }}" + chart_version: "24.1.0" + force: true + register: _result_force + + - name: Validate force parameter causes download + assert: + that: + - _result_force is changed + + # Test idempotency with untarred chart in the separate folder + - name: Create separate directory for untar test under {{ temp_dir }} + ansible.builtin.file: + path: "{{ destination }}/untar_test" + state: directory + mode: '0755' + + - name: Download and untar chart (first time) + helm_pull: + binary_path: "{{ helm_path }}" + chart_ref: "oci://registry-1.docker.io/bitnamicharts/redis" + destination: "{{ destination }}/untar_test" + chart_version: "24.0.0" + untar_chart: true + register: _result_untar_first + + - name: Download and untar chart (second time - should be idempotent) + helm_pull: + binary_path: "{{ helm_path }}" + chart_ref: "oci://registry-1.docker.io/bitnamicharts/redis" + destination: "{{ destination }}/untar_test" + chart_version: "24.0.0" + untar_chart: true + register: _result_untar_second + + - name: Validate idempotency for untarred chart + assert: + that: + - _result_untar_first is changed + - _result_untar_second is not changed + + - name: Download and untar chart with force=true (should remove existing directory and re-extract) + helm_pull: + binary_path: "{{ helm_path }}" + chart_ref: "oci://registry-1.docker.io/bitnamicharts/redis" + destination: "{{ destination }}/untar_test" + chart_version: "24.0.0" + untar_chart: true + force: true + register: _result_untar_force + + - name: Validate first force extraction works + assert: + that: + - _result_untar_force is changed + + - name: Verify chart directory still exists after force re-extraction + stat: + path: "{{ destination }}/untar_test/redis" + register: _chart_after_force + + - name: Validate chart directory exists + assert: + that: + - _chart_after_force.stat.exists + - _chart_after_force.stat.isdir + vars: helm_path: "{{ temp_dir }}/3.8.0/linux-amd64/helm"