Limit compatibility to Helm =>v3.0.0,<4.0.0 (#1039) (#1071)

This is a backport of PR #1039 as merged into main (13791ec).
SUMMARY
Helm v4 is a major version with backward-incompatible changes, including to the flags and output of the Helm CLI and to the SDK. This version is currently not supported in the kubernetes.core. This PR is related to #1038 and is a short-term solution to mark compatibility explicitly
ISSUE TYPE

Bugfix Pull Request
Docs Pull Request

COMPONENT NAME

helm
helm_template
helm_info
helm_repository
helm_pull
helm_registry_auth
helm_plugin
helm_plugin_info

ADDITIONAL INFORMATION
Added `validate_helm_version()`` method to AnsibleHelmModule that enforces version constraint >=3.0.0,<4.0.0.
Fails fast with clear error message: "Helm version must be >=3.0.0,<4.0.0, current version is {version}"
Some modules (i.e. helm_registry_auth) technically is compatible with Helm v4, but validation was added to all helm modules.
Partially coauthored by GitHub Copilot with Claude Sonnet 4 model.
Addresses issue #1038

Reviewed-by: Bianca Henderson <beeankha@gmail.com>
Reviewed-by: Mike Graves <mgraves@redhat.com>
This commit is contained in:
patchback[bot]
2026-01-26 19:47:46 +00:00
committed by GitHub
parent 66bd8620e4
commit 6edc84678d
19 changed files with 370 additions and 171 deletions

View File

@@ -7,3 +7,4 @@
- "v3.15.4"
- "v3.16.0"
- "v3.17.0"
- "v4.0.0"

View File

@@ -13,45 +13,53 @@
include_role:
name: install_helm
- name: "Ensure we honor the environment variables"
include_tasks: test_read_envvars.yml
- name: Main helm tests with Helm v3
when: helm_version != "v4.0.0"
block:
- name: Deploy charts
include_tasks: "tests_chart/{{ test_chart_type }}.yml"
loop_control:
loop_var: test_chart_type
with_items:
- from_local_path
- from_repository
- from_url
- name: "Ensure we honor the environment variables"
include_tasks: test_read_envvars.yml
when: helm_version != "v4.0.0"
- name: test helm upgrade with reuse_values
include_tasks: test_helm_reuse_values.yml
- name: Deploy charts
include_tasks: "tests_chart/{{ test_chart_type }}.yml"
loop_control:
loop_var: test_chart_type
with_items:
- from_local_path
- from_repository
- from_url
- name: test helm upgrade with reset_then_reuse_values
include_tasks: test_helm_reset_then_reuse_values.yml
- name: test helm upgrade with reuse_values
include_tasks: test_helm_reuse_values.yml
- name: test helm dependency update
include_tasks: test_up_dep.yml
- name: test helm upgrade with reset_then_reuse_values
include_tasks: test_helm_reset_then_reuse_values.yml
- name: Test helm uninstall
include_tasks: test_helm_uninstall.yml
- name: test helm dependency update
include_tasks: test_up_dep.yml
- name: Test helm install with chart name containing space
include_tasks: test_helm_with_space_into_chart_name.yml
- name: Test helm uninstall
include_tasks: test_helm_uninstall.yml
# https://github.com/ansible-collections/community.kubernetes/issues/296
- name: Test Skip CRDS feature in helm chart install
include_tasks: test_crds.yml
- name: Test helm install with chart name containing space
include_tasks: test_helm_with_space_into_chart_name.yml
- name: Test insecure registry flag feature
include_tasks: test_helm_insecure.yml
# https://github.com/ansible-collections/community.kubernetes/issues/296
- name: Test Skip CRDS feature in helm chart install
include_tasks: test_crds.yml
- name: Test take ownership flag feature
include_tasks: test_helm_take_ownership.yml
- name: Test insecure registry flag feature
include_tasks: test_helm_insecure.yml
- name: Test helm skip_schema_validation
include_tasks: test_skip_schema_validation.yml
- name: Test take ownership flag feature
include_tasks: test_helm_take_ownership.yml
- name: Test helm skip_schema_validation
include_tasks: test_skip_schema_validation.yml
- name: Test helm version
include_tasks: test_helm_version.yml
- name: Clean helm install
file:

View File

@@ -0,0 +1,47 @@
---
- name: Test helm reuse_values
vars:
helm_namespace: "{{ test_namespace[14] }}"
chart_release_values:
replica:
replicaCount: 3
master:
count: 1
kind: Deployment
chart_reuse_values:
replica:
replicaCount: 1
master:
count: 3
block:
- name: Initial chart installation
helm:
binary_path: "{{ helm_binary }}"
chart_ref: oci://registry-1.docker.io/bitnamicharts/redis
release_name: test-redis
release_namespace: "{{ helm_namespace }}"
create_namespace: true
release_values: "{{ chart_release_values }}"
register: install
ignore_errors: true
when: helm_version == "v4.0.0"
- name: Debug install result
debug:
var: install
when: helm_version == "v4.0.0"
- name: Ensure helm installation was failed for v4.0.0
assert:
that:
- install is failed
- "'Helm version must be >=3.0.0,<4.0.0' in install.msg"
when: helm_version == "v4.0.0"
always:
- name: Remove helm namespace
k8s:
api_version: v1
kind: Namespace
name: "{{ helm_namespace }}"
state: absent

View File

@@ -443,3 +443,46 @@ def test_module_get_helm_set_values_args(set_values, expected):
result = helm_module.get_helm_set_values_args(set_values)
assert " ".join(expected) == result
@pytest.mark.parametrize(
"helm_version,should_fail",
[
("3.0.0", False),
("3.5.0", False),
("3.10.3", False),
("3.15.0", False),
("3.17.0", False),
("2.9.0", True),
("2.17.0", True),
("4.0.0", True),
("4.1.0", True),
("5.0.0", True),
],
)
def test_module_validate_helm_version(_ansible_helm_module, helm_version, should_fail):
_ansible_helm_module.get_helm_version = MagicMock()
_ansible_helm_module.get_helm_version.return_value = helm_version
if should_fail:
with pytest.raises(SystemExit):
_ansible_helm_module.validate_helm_version()
_ansible_helm_module.fail_json.assert_called_once()
call_args = _ansible_helm_module.fail_json.call_args
assert "Helm version must be >=3.0.0,<4.0.0" in call_args[1]["msg"]
assert helm_version in call_args[1]["msg"]
else:
_ansible_helm_module.validate_helm_version()
_ansible_helm_module.fail_json.assert_not_called()
def test_module_validate_helm_version_none(_ansible_helm_module):
_ansible_helm_module.get_helm_version = MagicMock()
_ansible_helm_module.get_helm_version.return_value = None
with pytest.raises(SystemExit):
_ansible_helm_module.validate_helm_version()
_ansible_helm_module.fail_json.assert_called_once_with(
msg="Unable to determine Helm version"
)

View File

@@ -43,15 +43,21 @@ class TestDependencyUpdateWithoutChartRepoUrlOption(unittest.TestCase):
def test_dependency_update_option_not_defined(self):
set_module_args({"chart_ref": "/tmp/path"})
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm_template.main()
mock_run_command.assert_called_once_with(
"/usr/bin/helm template /tmp/path", environ_update={}, data=None
# Check the last call was the actual helm template command
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm template /tmp/path"
)
assert result.exception.args[0]["command"] == "/usr/bin/helm template /tmp/path"
@@ -64,17 +70,21 @@ class TestDependencyUpdateWithoutChartRepoUrlOption(unittest.TestCase):
}
)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm_template.main()
mock_run_command.assert_called_once_with(
"/usr/bin/helm template test --repo=https://charts.com/test",
environ_update={},
data=None,
# Check the last call was the actual helm template command
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm template test --repo=https://charts.com/test"
)
assert (
result.exception.args[0]["command"]
@@ -86,17 +96,21 @@ class TestDependencyUpdateWithoutChartRepoUrlOption(unittest.TestCase):
{"chart_ref": "https://charts/example.tgz", "dependency_update": True}
)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm_template.main()
mock_run_command.assert_called_once_with(
"/usr/bin/helm template https://charts/example.tgz --dependency-update",
environ_update={},
data=None,
# Check the last call was the actual helm template command
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm template https://charts/example.tgz --dependency-update"
)
assert (
result.exception.args[0]["command"]

View File

@@ -7,7 +7,7 @@ from __future__ import absolute_import, division, print_function
__metaclass__ = type
import unittest
from unittest.mock import MagicMock, call, patch
from unittest.mock import MagicMock, patch
from ansible.module_utils import basic
from ansible_collections.kubernetes.core.plugins.modules import helm
@@ -77,18 +77,22 @@ class TestDependencyUpdateWithoutChartRepoUrlOption(unittest.TestCase):
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_without_dep)
helm.run_dep_update = MagicMock()
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
helm.run_dep_update.assert_not_called()
mock_run_command.assert_called_once_with(
"/usr/bin/helm upgrade -i --reset-values test '/tmp/path'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
# Check the last call (actual helm command, after version check)
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm upgrade -i --reset-values test '/tmp/path'"
)
assert (
result.exception.args[0]["command"]
@@ -108,18 +112,22 @@ class TestDependencyUpdateWithoutChartRepoUrlOption(unittest.TestCase):
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_without_dep)
helm.run_dep_update = MagicMock()
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
helm.run_dep_update.assert_not_called()
mock_run_command.assert_called_once_with(
"/usr/bin/helm upgrade -i --reset-values test '/tmp/path'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
# Check the last call (actual helm command, after version check)
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm upgrade -i --reset-values test '/tmp/path'"
)
assert (
result.exception.args[0]["command"]
@@ -139,19 +147,23 @@ class TestDependencyUpdateWithoutChartRepoUrlOption(unittest.TestCase):
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_with_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = 0, "configuration updated", ""
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with patch.object(basic.AnsibleModule, "warn") as mock_warn:
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
mock_warn.assert_not_called()
mock_run_command.assert_has_calls(
[
call(
"/usr/bin/helm upgrade -i --reset-values test '/tmp/path'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
)
]
# Check calls include the actual helm command (after version check)
assert any(
"/usr/bin/helm upgrade -i --reset-values test '/tmp/path'" in str(call)
for call in mock_run_command.call_args_list
)
assert (
result.exception.args[0]["command"]
@@ -170,23 +182,23 @@ class TestDependencyUpdateWithoutChartRepoUrlOption(unittest.TestCase):
helm.get_release_status = MagicMock(return_value=None)
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_without_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with patch.object(basic.AnsibleModule, "warn") as mock_warn:
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
mock_warn.assert_called_once()
mock_run_command.assert_has_calls(
[
call(
"/usr/bin/helm upgrade -i --reset-values test '/tmp/path'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
)
]
# Check calls include the actual helm command (after version check)
assert any(
"/usr/bin/helm upgrade -i --reset-values test '/tmp/path'" in str(call)
for call in mock_run_command.call_args_list
)
assert (
result.exception.args[0]["command"]
@@ -245,17 +257,21 @@ class TestDependencyUpdateWithChartRepoUrlOption(unittest.TestCase):
helm.get_release_status = MagicMock(return_value=None)
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_without_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
mock_run_command.assert_called_once_with(
"/usr/bin/helm --repo=http://repo.example/charts upgrade -i --reset-values test 'chart1'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
# Check the last call (actual helm command, after version check)
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm --repo=http://repo.example/charts upgrade -i --reset-values test 'chart1'"
)
assert (
result.exception.args[0]["command"]
@@ -275,17 +291,21 @@ class TestDependencyUpdateWithChartRepoUrlOption(unittest.TestCase):
helm.get_release_status = MagicMock(return_value=None)
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_without_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
mock_run_command.assert_called_once_with(
"/usr/bin/helm --repo=http://repo.example/charts upgrade -i --reset-values test 'chart1'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
# Check the last call (actual helm command, after version check)
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm --repo=http://repo.example/charts upgrade -i --reset-values test 'chart1'"
)
assert (
result.exception.args[0]["command"]
@@ -305,11 +325,15 @@ class TestDependencyUpdateWithChartRepoUrlOption(unittest.TestCase):
helm.get_release_status = MagicMock(return_value=None)
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_with_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleFailJson) as result:
helm.main()
# mock_run_command.assert_called_once_with('/usr/bin/helm --repo=http://repo.example/charts upgrade -i --reset-values test chart1',
@@ -334,17 +358,21 @@ class TestDependencyUpdateWithChartRepoUrlOption(unittest.TestCase):
helm.get_release_status = MagicMock(return_value=None)
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_without_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
mock_run_command.assert_called_once_with(
"/usr/bin/helm --repo=http://repo.example/charts install --dependency-update --replace test 'chart1'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
# Check the last call (actual helm command, after version check)
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm --repo=http://repo.example/charts install --dependency-update --replace test 'chart1'"
)
assert (
result.exception.args[0]["command"]
@@ -402,17 +430,21 @@ class TestDependencyUpdateWithChartRefIsUrl(unittest.TestCase):
helm.get_release_status = MagicMock(return_value=None)
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_without_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
mock_run_command.assert_called_once_with(
"/usr/bin/helm upgrade -i --reset-values test 'http://repo.example/charts/application.tgz'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
# Check the last call (actual helm command, after version check)
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm upgrade -i --reset-values test 'http://repo.example/charts/application.tgz'"
)
assert (
result.exception.args[0]["command"]
@@ -431,17 +463,21 @@ class TestDependencyUpdateWithChartRefIsUrl(unittest.TestCase):
helm.get_release_status = MagicMock(return_value=None)
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_without_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
mock_run_command.assert_called_once_with(
"/usr/bin/helm upgrade -i --reset-values test 'http://repo.example/charts/application.tgz'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
# Check the last call (actual helm command, after version check)
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm upgrade -i --reset-values test 'http://repo.example/charts/application.tgz'"
)
assert (
result.exception.args[0]["command"]
@@ -460,11 +496,15 @@ class TestDependencyUpdateWithChartRefIsUrl(unittest.TestCase):
helm.get_release_status = MagicMock(return_value=None)
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_with_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleFailJson) as result:
helm.main()
# mock_run_command.assert_called_once_with('/usr/bin/helm --repo=http://repo.example/charts upgrade -i --reset-values test chart1',
@@ -488,17 +528,21 @@ class TestDependencyUpdateWithChartRefIsUrl(unittest.TestCase):
helm.get_release_status = MagicMock(return_value=None)
helm.fetch_chart_info = MagicMock(return_value=self.chart_info_without_dep)
with patch.object(basic.AnsibleModule, "run_command") as mock_run_command:
mock_run_command.return_value = (
0,
"configuration updated",
"",
) # successful execution
# Mock responses: first call is helm version, second is the actual command
mock_run_command.side_effect = [
(
0,
'version.BuildInfo{Version:"v3.10.0", GitCommit:"", GoVersion:"go1.18"}',
"",
),
(0, "configuration updated", ""),
]
with self.assertRaises(AnsibleExitJson) as result:
helm.main()
mock_run_command.assert_called_once_with(
"/usr/bin/helm install --dependency-update --replace test 'http://repo.example/charts/application.tgz'",
environ_update={"HELM_NAMESPACE": "test"},
data=None,
# Check the last call (actual helm command, after version check)
assert (
mock_run_command.call_args_list[-1][0][0]
== "/usr/bin/helm install --dependency-update --replace test 'http://repo.example/charts/application.tgz'"
)
assert (
result.exception.args[0]["command"]