diff --git a/changelogs/fragments/1201-docker_network.yml b/changelogs/fragments/1201-docker_network.yml new file mode 100644 index 00000000..0c226873 --- /dev/null +++ b/changelogs/fragments/1201-docker_network.yml @@ -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://github.com/ansible-collections/community.docker/pull/1203)." diff --git a/plugins/module_utils/util.py b/plugins/module_utils/util.py index 56b41dc0..ed54bfc8 100644 --- a/plugins/module_utils/util.py +++ b/plugins/module_utils/util.py @@ -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 diff --git a/plugins/modules/docker_network.py b/plugins/modules/docker_network.py index db81a281..402ff1b3 100644 --- a/plugins/modules/docker_network.py +++ b/plugins/modules/docker_network.py @@ -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))