mirror of
https://github.com/ansible-collections/kubernetes.core.git
synced 2026-07-29 19:04:39 +00:00
* 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 3634105366)
Co-authored-by: Yuriy Novostavskiy <yuriy@novostavskiy.kyiv.ua>
This commit is contained in:
4
changelogs/fragments/20260629-helm-force-helm-v4.yml
Normal file
4
changelogs/fragments/20260629-helm-force-helm-v4.yml
Normal file
@@ -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).
|
||||||
@@ -165,6 +165,13 @@ options:
|
|||||||
force:
|
force:
|
||||||
description:
|
description:
|
||||||
- Helm option to force reinstall, ignore on new install.
|
- 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
|
default: False
|
||||||
type: bool
|
type: bool
|
||||||
purge:
|
purge:
|
||||||
@@ -197,6 +204,27 @@ options:
|
|||||||
- If set, the installation process deletes the installation on failure.
|
- If set, the installation process deletes the installation on failure.
|
||||||
type: bool
|
type: bool
|
||||||
default: False
|
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:
|
create_namespace:
|
||||||
description:
|
description:
|
||||||
- Create the release namespace if not present.
|
- Create the release namespace if not present.
|
||||||
@@ -590,6 +618,8 @@ def deploy(
|
|||||||
plain_http=False,
|
plain_http=False,
|
||||||
take_ownership=False,
|
take_ownership=False,
|
||||||
skip_schema_validation=False,
|
skip_schema_validation=False,
|
||||||
|
server_side=None,
|
||||||
|
force_conflicts=False,
|
||||||
):
|
):
|
||||||
"""
|
"""
|
||||||
Install/upgrade/rollback release chart
|
Install/upgrade/rollback release chart
|
||||||
@@ -631,8 +661,38 @@ def deploy(
|
|||||||
if timeout:
|
if timeout:
|
||||||
deploy_command += " --timeout " + 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:
|
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:
|
if replace:
|
||||||
deploy_command += " --replace"
|
deploy_command += " --replace"
|
||||||
@@ -893,6 +953,8 @@ def argument_spec():
|
|||||||
wait_timeout=dict(type="str"),
|
wait_timeout=dict(type="str"),
|
||||||
timeout=dict(type="str"),
|
timeout=dict(type="str"),
|
||||||
atomic=dict(type="bool", default=False),
|
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),
|
create_namespace=dict(type="bool", default=False),
|
||||||
post_renderer=dict(type="str"),
|
post_renderer=dict(type="str"),
|
||||||
replace=dict(type="bool", default=False),
|
replace=dict(type="bool", default=False),
|
||||||
@@ -925,6 +987,8 @@ def main():
|
|||||||
("context", "ca_cert"),
|
("context", "ca_cert"),
|
||||||
("replace", "history_max"),
|
("replace", "history_max"),
|
||||||
("wait_timeout", "timeout"),
|
("wait_timeout", "timeout"),
|
||||||
|
("force", "server_side"),
|
||||||
|
("force", "force_conflicts"),
|
||||||
],
|
],
|
||||||
supports_check_mode=True,
|
supports_check_mode=True,
|
||||||
)
|
)
|
||||||
@@ -954,6 +1018,8 @@ def main():
|
|||||||
wait = module.params.get("wait")
|
wait = module.params.get("wait")
|
||||||
wait_timeout = module.params.get("wait_timeout")
|
wait_timeout = module.params.get("wait_timeout")
|
||||||
atomic = module.params.get("atomic")
|
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")
|
create_namespace = module.params.get("create_namespace")
|
||||||
post_renderer = module.params.get("post_renderer")
|
post_renderer = module.params.get("post_renderer")
|
||||||
replace = module.params.get("replace")
|
replace = module.params.get("replace")
|
||||||
@@ -1082,6 +1148,8 @@ def main():
|
|||||||
False,
|
False,
|
||||||
values_files=values_files,
|
values_files=values_files,
|
||||||
atomic=atomic,
|
atomic=atomic,
|
||||||
|
server_side=server_side,
|
||||||
|
force_conflicts=force_conflicts,
|
||||||
create_namespace=create_namespace,
|
create_namespace=create_namespace,
|
||||||
post_renderer=post_renderer,
|
post_renderer=post_renderer,
|
||||||
replace=replace,
|
replace=replace,
|
||||||
@@ -1166,6 +1234,8 @@ def main():
|
|||||||
force,
|
force,
|
||||||
values_files=values_files,
|
values_files=values_files,
|
||||||
atomic=atomic,
|
atomic=atomic,
|
||||||
|
server_side=server_side,
|
||||||
|
force_conflicts=force_conflicts,
|
||||||
create_namespace=create_namespace,
|
create_namespace=create_namespace,
|
||||||
post_renderer=post_renderer,
|
post_renderer=post_renderer,
|
||||||
replace=replace,
|
replace=replace,
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ import unittest
|
|||||||
from unittest.mock import MagicMock, patch
|
from unittest.mock import MagicMock, patch
|
||||||
|
|
||||||
from ansible.module_utils import basic
|
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.plugins.modules import helm
|
||||||
from ansible_collections.kubernetes.core.tests.unit.utils.ansible_module_mock import (
|
from ansible_collections.kubernetes.core.tests.unit.utils.ansible_module_mock import (
|
||||||
AnsibleExitJson,
|
AnsibleExitJson,
|
||||||
@@ -548,3 +551,162 @@ class TestDependencyUpdateWithChartRefIsUrl(unittest.TestCase):
|
|||||||
result.exception.args[0]["command"]
|
result.exception.args[0]["command"]
|
||||||
== "/usr/bin/helm install --dependency-update --replace test 'http://repo.example/charts/application.tgz'"
|
== "/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()
|
||||||
|
|||||||
Reference in New Issue
Block a user