mirror of
https://github.com/ansible-collections/kubernetes.core.git
synced 2026-03-26 21:33:02 +00:00
Add idempotency to helm_pull module (#1055)
SUMMARY This PR implements idempotency for the helm_pull module, addressing issue #889. New force parameter with defaults to False. implemented chart_exists() function checks chart existence before downloading, returns changed=False when chart exists ISSUE TYPE Bugfix Pull Request COMPONENT NAME helm_pull ADDITIONAL INFORMATION Force parameter added for backward compatibility and edge cases. Implemented with the partial support of GitHub Copilot with Claude Sonnet 4.5 model Reviewed-by: Bikouo Aubin Reviewed-by: Yuriy Novostavskiy <yuriy@novostavskiy.kyiv.ua> Reviewed-by: Bianca Henderson <beeankha@gmail.com> Reviewed-by: Alina Buzachis
This commit is contained in:
committed by
GitHub
parent
3e32c12c40
commit
34beacf32b
@@ -0,0 +1,2 @@
|
||||
bugfixes:
|
||||
- Add idempotency for ``helm_pull`` module (https://github.com/ansible-collections/kubernetes.core/pull/1055).
|
||||
@@ -174,6 +174,28 @@ Parameters
|
||||
<div>location to write the chart.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="1">
|
||||
<div class="ansibleOptionAnchor" id="parameter-"></div>
|
||||
<b>force</b>
|
||||
<a class="ansibleOptionLink" href="#parameter-" title="Permalink to this option"></a>
|
||||
<div style="font-size: small">
|
||||
<span style="color: purple">boolean</span>
|
||||
</div>
|
||||
<div style="font-style: italic; font-size: small; color: darkgreen">added in 6.3.0</div>
|
||||
</td>
|
||||
<td>
|
||||
<ul style="margin: 0; padding: 0"><b>Choices:</b>
|
||||
<li><div style="color: blue"><b>no</b> ←</div></li>
|
||||
<li>yes</li>
|
||||
</ul>
|
||||
</td>
|
||||
<td>
|
||||
<div>Force download of the chart even if it already exists in the destination directory.</div>
|
||||
<div>By default, the module will skip downloading if the chart with the same version already exists for idempotency.</div>
|
||||
<div>When used with O(untar_chart=true), will remove any existing chart directory before extracting.</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="1">
|
||||
<div class="ansibleOptionAnchor" id="parameter-"></div>
|
||||
@@ -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 <https://docs.ansible.com/projects/ans
|
||||
<div style="font-size: smaller; color: blue; word-wrap: break-word; word-break: break-all;">helm pull --repo test ...</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="1">
|
||||
<div class="ansibleOptionAnchor" id="return-"></div>
|
||||
<b>msg</b>
|
||||
<a class="ansibleOptionLink" href="#return-" title="Permalink to this return value"></a>
|
||||
<div style="font-size: small">
|
||||
<span style="color: purple">string</span>
|
||||
</div>
|
||||
</td>
|
||||
<td>when chart already exists</td>
|
||||
<td>
|
||||
<div>A message indicating the result of the operation.</div>
|
||||
<br/>
|
||||
<div style="font-size: smaller"><b>Sample:</b></div>
|
||||
<div style="font-size: smaller; color: blue; word-wrap: break-word; word-break: break-all;">Chart redis version 17.0.0 already exists in destination directory</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colspan="1">
|
||||
<div class="ansibleOptionAnchor" id="return-"></div>
|
||||
|
||||
@@ -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-name>/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, "", "")
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user