From 08143fc16f9ac456dafd5971821d1e465eb6118f Mon Sep 17 00:00:00 2001 From: "patchback[bot]" <45432694+patchback[bot]@users.noreply.github.com> Date: Wed, 1 Jul 2026 14:12:24 -0400 Subject: [PATCH] fix(helm): support Helm v4 force/server-side apply flags (#1164) (#1177) * fix(helm): support Helm v4 force/server-side apply flags Helm v4 deprecated --force and enables server-side apply by default, so playbooks relying on force=true broke with "Flag --force has been deprecated" and "cannot use server-side apply and force replace together". Because force always meant a client-side delete/recreate replacement, the module now reproduces that behaviour on v4 (--server-side=false --force-replace) so existing playbooks keep working unchanged across both Helm versions. To let users opt into Helm v4's server-side world explicitly, add server_side and force_conflicts options. These are mutually exclusive with force (a client-side replacement cannot coexist with a server-side mode), keeping the surface unambiguous and surfacing bad combinations up front instead of as cryptic Helm errors. AI-assisted commit with Claude Opus * fix(linter) reformat tests/unit/modules/test_module_helm.py with black (cherry picked from commit 3634105366565019644b1750e0dc7cad8f8d038b) Co-authored-by: Yuriy Novostavskiy --- .../fragments/20260629-helm-force-helm-v4.yml | 4 + plugins/modules/helm.py | 72 +++++++- tests/unit/modules/test_module_helm.py | 162 ++++++++++++++++++ 3 files changed, 237 insertions(+), 1 deletion(-) create mode 100644 changelogs/fragments/20260629-helm-force-helm-v4.yml diff --git a/changelogs/fragments/20260629-helm-force-helm-v4.yml b/changelogs/fragments/20260629-helm-force-helm-v4.yml new file mode 100644 index 00000000..95b42457 --- /dev/null +++ b/changelogs/fragments/20260629-helm-force-helm-v4.yml @@ -0,0 +1,4 @@ +minor_changes: + - helm - add the ``server_side`` and ``force_conflicts`` options to control Helm v4 server-side apply when installing or upgrading a release (https://github.com/ansible-collections/kubernetes.core/pull/1164). +bugfixes: + - helm - use ``--server-side=false --force-replace`` instead of the deprecated/removed ``--force`` flag when ``force=true`` is used with Helm v4, preserving the Helm v3 client-side replacement behaviour and avoiding the server-side apply conflict (https://github.com/ansible-collections/kubernetes.core/pull/1164). diff --git a/plugins/modules/helm.py b/plugins/modules/helm.py index 627d527a..9ac209ad 100644 --- a/plugins/modules/helm.py +++ b/plugins/modules/helm.py @@ -165,6 +165,13 @@ options: force: description: - Helm option to force reinstall, ignore on new install. + - With Helm v4 this performs a client-side replacement. Helm v4 deprecated + C(--force) (split into C(--force-replace) and C(--force-conflicts)) and + enables server-side apply by default, so for Helm v4 the module emits + C(--server-side=false --force-replace) to preserve the Helm v3 behaviour. + With Helm v3 the C(--force) flag is used. The O(force) option works the + same way regardless of the installed Helm version. + - Mutually exclusive with O(server_side) and O(force_conflicts). default: False type: bool purge: @@ -197,6 +204,27 @@ options: - If set, the installation process deletes the installation on failure. type: bool default: False + server_side: + description: + - Control Helm v4 server-side apply when installing or upgrading a release. + - Maps to the C(--server-side) flag, which accepts C(true), C(false) or C(auto). + - This option requires Helm v4 and is mutually exclusive with O(force). + type: str + choices: + - "auto" + - "true" + - "false" + version_added: 6.5.0 + force_conflicts: + description: + - When server-side apply is enabled (the Helm v4 default), force changes + against conflicts instead of failing. + - Maps to the C(--force-conflicts) flag. + - This option requires Helm v4, cannot be combined with O(server_side=false), + and is mutually exclusive with O(force). + type: bool + default: False + version_added: 6.5.0 create_namespace: description: - Create the release namespace if not present. @@ -590,6 +618,8 @@ def deploy( plain_http=False, take_ownership=False, skip_schema_validation=False, + server_side=None, + force_conflicts=False, ): """ Install/upgrade/rollback release chart @@ -631,8 +661,38 @@ def deploy( if timeout: deploy_command += " --timeout " + timeout + if server_side is not None: + if not module.is_helm_v4(): + module.fail_json( + msg="server_side requires helm >= 4.0.0, current version is {0}".format( + module.get_helm_version() + ) + ) + deploy_command += " --server-side=" + server_side + + if force_conflicts: + if not module.is_helm_v4(): + module.fail_json( + msg="force_conflicts requires helm >= 4.0.0, current version is {0}".format( + module.get_helm_version() + ) + ) + if server_side == "false": + module.fail_json( + msg="force_conflicts requires server-side apply to be enabled and cannot be used with server_side=false" + ) + deploy_command += " --force-conflicts" + if force: - deploy_command += " --force" + # Helm v4 deprecated '--force' (split into '--force-replace' and + # '--force-conflicts') and enables server-side apply by default, which is + # incompatible with '--force-replace'. Emit the version-appropriate flags, + # disabling server-side apply on v4 to preserve the v3 client-side + # replacement behaviour. 'force' is mutually exclusive with 'server_side'. + if module.is_helm_v4(): + deploy_command += " --server-side=false --force-replace" + else: + deploy_command += " --force" if replace: deploy_command += " --replace" @@ -893,6 +953,8 @@ def argument_spec(): wait_timeout=dict(type="str"), timeout=dict(type="str"), atomic=dict(type="bool", default=False), + server_side=dict(type="str", choices=["auto", "true", "false"]), + force_conflicts=dict(type="bool", default=False), create_namespace=dict(type="bool", default=False), post_renderer=dict(type="str"), replace=dict(type="bool", default=False), @@ -925,6 +987,8 @@ def main(): ("context", "ca_cert"), ("replace", "history_max"), ("wait_timeout", "timeout"), + ("force", "server_side"), + ("force", "force_conflicts"), ], supports_check_mode=True, ) @@ -954,6 +1018,8 @@ def main(): wait = module.params.get("wait") wait_timeout = module.params.get("wait_timeout") atomic = module.params.get("atomic") + server_side = module.params.get("server_side") + force_conflicts = module.params.get("force_conflicts") create_namespace = module.params.get("create_namespace") post_renderer = module.params.get("post_renderer") replace = module.params.get("replace") @@ -1082,6 +1148,8 @@ def main(): False, values_files=values_files, atomic=atomic, + server_side=server_side, + force_conflicts=force_conflicts, create_namespace=create_namespace, post_renderer=post_renderer, replace=replace, @@ -1166,6 +1234,8 @@ def main(): force, values_files=values_files, atomic=atomic, + server_side=server_side, + force_conflicts=force_conflicts, create_namespace=create_namespace, post_renderer=post_renderer, replace=replace, diff --git a/tests/unit/modules/test_module_helm.py b/tests/unit/modules/test_module_helm.py index b019aafb..6dd63889 100644 --- a/tests/unit/modules/test_module_helm.py +++ b/tests/unit/modules/test_module_helm.py @@ -10,6 +10,9 @@ import unittest from unittest.mock import MagicMock, patch from ansible.module_utils import basic +from ansible_collections.kubernetes.core.plugins.module_utils.helm import ( + AnsibleHelmModule, +) from ansible_collections.kubernetes.core.plugins.modules import helm from ansible_collections.kubernetes.core.tests.unit.utils.ansible_module_mock import ( AnsibleExitJson, @@ -548,3 +551,162 @@ class TestDependencyUpdateWithChartRefIsUrl(unittest.TestCase): result.exception.args[0]["command"] == "/usr/bin/helm install --dependency-update --replace test 'http://repo.example/charts/application.tgz'" ) + + +class TestForceFlagHelmVersion(unittest.TestCase): + def setUp(self): + self.mock_module_helper = patch.multiple( + basic.AnsibleModule, + exit_json=exit_json, + fail_json=fail_json, + get_bin_path=get_bin_path, + ) + self.mock_module_helper.start() + self.addCleanup(self.mock_module_helper.stop) + + self.chart_info = { + "apiVersion": "v2", + "appVersion": "default", + "description": "A chart used in molecule tests", + "name": "test-chart", + "type": "application", + "version": "0.1.0", + } + + def _deploy_command(self, params, helm_version, release_status=None): + """Run helm.main() with mocked dependencies and return the helm command.""" + set_module_args( + { + "release_name": "test", + "release_namespace": "test", + "chart_ref": "/tmp/path", + **params, + } + ) + helm.get_release_status = MagicMock(return_value=release_status) + helm.fetch_chart_info = MagicMock(return_value=self.chart_info) + # Force the default (non helm-diff) idempotency check path for upgrades. + helm.get_plugin_version = MagicMock(return_value=None) + helm.default_check = MagicMock(return_value=False) + with patch.object( + AnsibleHelmModule, "get_helm_version", return_value=helm_version + ): + with patch.object(basic.AnsibleModule, "run_command") as mock_run_command: + mock_run_command.return_value = (0, "deployed", "") + try: + helm.main() + except AnsibleExitJson: + pass + return mock_run_command.call_args_list[-1][0][0] + + # --- force flag (existing release, so the upgrade path is exercised) --- + + def test_force_uses_force_on_helm_v3(self): + cmd = self._deploy_command( + {"force": True}, "3.10.0", release_status={"status": "deployed"} + ) + assert " --force" in cmd + assert "--force-replace" not in cmd + + def test_force_uses_force_replace_on_helm_v4(self): + cmd = self._deploy_command( + {"force": True}, "4.0.0", release_status={"status": "deployed"} + ) + assert " --server-side=false --force-replace" in cmd + assert " --force " not in cmd + + # --- server_side option (fresh install path) --- + + def test_server_side_on_helm_v4(self): + cmd = self._deploy_command({"server_side": "true"}, "4.0.0") + assert " --server-side=true" in cmd + + def test_server_side_requires_helm_v4(self): + set_module_args( + { + "release_name": "test", + "release_namespace": "test", + "chart_ref": "/tmp/path", + "server_side": "true", + } + ) + helm.get_release_status = MagicMock(return_value=None) + helm.fetch_chart_info = MagicMock(return_value=self.chart_info) + with patch.object(AnsibleHelmModule, "get_helm_version", return_value="3.10.0"): + with patch.object(basic.AnsibleModule, "run_command") as mock_run_command: + mock_run_command.return_value = (0, "deployed", "") + with self.assertRaises(AnsibleFailJson): + helm.main() + + # --- force_conflicts option (fresh install path) --- + + def test_force_conflicts_on_helm_v4(self): + cmd = self._deploy_command({"force_conflicts": True}, "4.0.0") + assert " --force-conflicts" in cmd + + def test_force_conflicts_with_server_side(self): + cmd = self._deploy_command( + {"force_conflicts": True, "server_side": "auto"}, "4.0.0" + ) + assert " --server-side=auto --force-conflicts" in cmd + + def test_force_conflicts_requires_helm_v4(self): + set_module_args( + { + "release_name": "test", + "release_namespace": "test", + "chart_ref": "/tmp/path", + "force_conflicts": True, + } + ) + helm.get_release_status = MagicMock(return_value=None) + helm.fetch_chart_info = MagicMock(return_value=self.chart_info) + with patch.object(AnsibleHelmModule, "get_helm_version", return_value="3.10.0"): + with patch.object(basic.AnsibleModule, "run_command") as mock_run_command: + mock_run_command.return_value = (0, "deployed", "") + with self.assertRaises(AnsibleFailJson): + helm.main() + + def test_force_conflicts_conflicts_with_server_side_false(self): + set_module_args( + { + "release_name": "test", + "release_namespace": "test", + "chart_ref": "/tmp/path", + "force_conflicts": True, + "server_side": "false", + } + ) + helm.get_release_status = MagicMock(return_value=None) + helm.fetch_chart_info = MagicMock(return_value=self.chart_info) + with patch.object(AnsibleHelmModule, "get_helm_version", return_value="4.0.0"): + with patch.object(basic.AnsibleModule, "run_command") as mock_run_command: + mock_run_command.return_value = (0, "deployed", "") + with self.assertRaises(AnsibleFailJson): + helm.main() + + # --- mutually exclusive combinations (rejected at module init) --- + + def test_force_and_server_side_are_mutually_exclusive(self): + with self.assertRaises(AnsibleFailJson): + set_module_args( + { + "release_name": "test", + "chart_ref": "/tmp/path", + "force": True, + "server_side": "true", + } + ) + helm.main() + + def test_force_and_force_conflicts_are_mutually_exclusive(self): + with self.assertRaises(AnsibleFailJson): + set_module_args( + { + "release_name": "test", + "chart_ref": "/tmp/path", + "force": True, + "force_conflicts": True, + } + ) + helm.main()