[stable-4] docker_network: fix IP subnet and address idempotency (#1203)

* Fix IP subnet and address idempotency. (#1201)

(cherry picked from commit 3da2799e03)

* Add warning about missing normalization if ipaddress is not there on Python 2.

* Fix mistake.
This commit is contained in:
Felix Fontein 2025-11-16 11:18:47 +01:00 committed by GitHub
parent 99a81449c5
commit 8f50319434
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 61 additions and 4 deletions

View File

@ -0,0 +1,8 @@
bugfixes:
- "docker_network - fix idempotency for IPv6 addresses and networks with Docker 29.0.0 (https://github.com/ansible-collections/community.docker/pull/1201)."
known_issues:
- "docker_network - when specifying IPv6 addresses or networks, Docker since version 29 no longer returns the orignal address/network used
when creating a network, but normalizes them. The module will try to normalize IP addresses for comparison,
but it uses the ``ipaddress`` module from the Python 3 standard library for that. When using the module with Python 2,
please install the `ipaddress backport for Python 2.x <https://pypi.org/project/ipaddress/>`__
(https://github.com/ansible-collections/community.docker/pull/1203)."

View File

@ -447,3 +447,21 @@ def normalize_ip_address(ip_address):
# If we don't have ipaddress, simply give up...
# This mainly affects Python 2.7.
return ip_address
def normalize_ip_network(network):
"""
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
if HAS_IPADDRESS:
try:
return ipaddress.ip_network(network).compressed
except ValueError:
# Fallback for invalid networks: simply return the input
return network
# If we don't have ipaddress, simply give up...
# This mainly affects Python 2.7.
return network

View File

@ -193,6 +193,10 @@ notes:
- The module does not support Docker Swarm. This means that it will not try to disconnect or reconnect services. If services
are connected to the network, deleting the network will fail. When network options are changed, the network has to be
deleted and recreated, so this will fail as well.
- When specifying IPv6 addresses for networks, Docker since version 29 no longer returns the orignal address used
when creating a network, but normalizes them. The module will try to normalize IP addresses for comparison,
but it uses the C(ipaddress) module from the Python 3 standard library for that. When using the module with Python 2,
please install the L(ipaddress backport for Python 2.x, https://pypi.org/project/ipaddress/).
author:
- "Ben Keith (@keitwb)"
- "Chris Houseknecht (@chouseknecht)"
@ -296,6 +300,8 @@ from ansible_collections.community.docker.plugins.module_utils.util import (
DockerBaseClass,
DifferenceTracker,
clean_dict_booleans_for_docker_api,
normalize_ip_address,
normalize_ip_network,
sanitize_labels,
)
from ansible_collections.community.docker.plugins.module_utils._api.errors import DockerException
@ -352,6 +358,7 @@ def validate_cidr(cidr):
: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'
elif CIDR_IPV6.match(cidr):
@ -383,6 +390,19 @@ def dicts_are_essentially_equal(a, b):
return True
def normalize_ipam_values(ipam_config):
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(object):
def __init__(self, client):
@ -480,24 +500,35 @@ class DockerNetworkManager(object):
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 = dict()
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):
net_config = dict()
for net_ipam_config in net_ipam_configs:
if dicts_are_essentially_equal(ipam_config, net_ipam_config):
ipam_config_normalized = normalize_ipam_values(ipam_config)
net_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('ipam_config[%s].%s' % (idx, key),
parameter=value,
active=net_config.get(key))