From 3da2799e037e0de578bb91e6111ce106ced8ffab Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Sun, 16 Nov 2025 10:47:35 +0100 Subject: [PATCH] Fix IP subnet and address idempotency. (#1201) --- changelogs/fragments/1201-docker_network.yml | 2 ++ plugins/module_utils/_util.py | 22 +++++++++++++ plugins/modules/docker_network.py | 33 ++++++++++++++++++-- 3 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 changelogs/fragments/1201-docker_network.yml diff --git a/changelogs/fragments/1201-docker_network.yml b/changelogs/fragments/1201-docker_network.yml new file mode 100644 index 00000000..de54be3d --- /dev/null +++ b/changelogs/fragments/1201-docker_network.yml @@ -0,0 +1,2 @@ +bugfixes: + - "docker_network - fix idempotency for IPv6 addresses and networks with Docker 29.0.0 (https://github.com/ansible-collections/community.docker/pull/1201)." diff --git a/plugins/module_utils/_util.py b/plugins/module_utils/_util.py index 45452a01..743548ca 100644 --- a/plugins/module_utils/_util.py +++ b/plugins/module_utils/_util.py @@ -528,3 +528,25 @@ def normalize_ip_address(ip_address: str | None) -> str | None: except ValueError: # Fallback for invalid addresses: simply return the input return ip_address + + +@t.overload +def normalize_ip_network(network: str) -> str: ... + + +@t.overload +def normalize_ip_network(network: str | None) -> str | None: ... + + +def normalize_ip_network(network: str | None) -> str | None: + """ + Given a network in CIDR notation as a string, normalize it so that it can be + used to compare networks as strings. + """ + if network is None: + return None + try: + return ipaddress.ip_network(network).compressed + except ValueError: + # Fallback for invalid networks: simply return the input + return network diff --git a/plugins/modules/docker_network.py b/plugins/modules/docker_network.py index 9145fda4..e6063874 100644 --- a/plugins/modules/docker_network.py +++ b/plugins/modules/docker_network.py @@ -299,6 +299,8 @@ from ansible_collections.community.docker.plugins.module_utils._util import ( DifferenceTracker, DockerBaseClass, clean_dict_booleans_for_docker_api, + normalize_ip_address, + normalize_ip_network, sanitize_labels, ) @@ -360,6 +362,7 @@ def validate_cidr(cidr: str) -> t.Literal["ipv4", "ipv6"]: :rtype: str :raises ValueError: If ``cidr`` is not a valid CIDR """ + # TODO: Use ipaddress for this instead of rolling your own... if CIDR_IPV4.match(cidr): return "ipv4" if CIDR_IPV6.match(cidr): @@ -389,6 +392,19 @@ def dicts_are_essentially_equal(a: dict[str, t.Any], b: dict[str, t.Any]) -> boo return True +def normalize_ipam_values(ipam_config: dict[str, t.Any]) -> dict[str, t.Any]: + result = {} + for key, value in ipam_config.items(): + if key in ("subnet", "iprange"): + value = normalize_ip_network(value) + elif key in ("gateway",): + value = normalize_ip_address(value) + elif key in ("aux_addresses",) and value is not None: + value = {k: normalize_ip_address(v) for k, v in value.items()} + result[key] = value + return result + + class DockerNetworkManager: def __init__(self, client: AnsibleDockerClient) -> None: self.client = client @@ -513,24 +529,35 @@ class DockerNetworkManager: else: # Put network's IPAM config into the same format as module's IPAM config net_ipam_configs = [] + net_ipam_configs_normalized = [] for net_ipam_config in net["IPAM"]["Config"]: config = {} for k, v in net_ipam_config.items(): config[normalize_ipam_config_key(k)] = v net_ipam_configs.append(config) + net_ipam_configs_normalized.append(normalize_ipam_values(config)) # Compare lists of dicts as sets of dicts for idx, ipam_config in enumerate(self.parameters.ipam_config): + ipam_config_normalized = normalize_ipam_values(ipam_config) net_config = {} - for net_ipam_config in net_ipam_configs: - if dicts_are_essentially_equal(ipam_config, net_ipam_config): + net_config_normalized = {} + for net_ipam_config, net_ipam_config_normalized in zip( + net_ipam_configs, net_ipam_configs_normalized + ): + if dicts_are_essentially_equal( + ipam_config_normalized, net_ipam_config_normalized + ): net_config = net_ipam_config + net_config_normalized = net_ipam_config_normalized break for key, value in ipam_config.items(): if value is None: # due to recursive argument_spec, all keys are always present # (but have default value None if not specified) continue - if value != net_config.get(key): + if ipam_config_normalized[key] != net_config_normalized.get( + key + ): differences.add( f"ipam_config[{idx}].{key}", parameter=value,