diff --git a/changelogs/fragments/35370-add_support_for_docker_network_internal_flag.yaml b/changelogs/fragments/35370-add_support_for_docker_network_internal_flag.yaml index c55f7c46a31..8e1d2ab4ea6 100644 --- a/changelogs/fragments/35370-add_support_for_docker_network_internal_flag.yaml +++ b/changelogs/fragments/35370-add_support_for_docker_network_internal_flag.yaml @@ -1,4 +1,3 @@ --- minor_changes: - "docker_network - ``internal`` is now used to set the ``Internal`` property of the docker network during creation." - - "docker_network - Minimum docker-py version increased from ``1.8.0`` to ``1.9.0``." diff --git a/changelogs/fragments/47492-docker_network-add-ipv6-support.yaml b/changelogs/fragments/47492-docker_network-add-ipv6-support.yaml new file mode 100644 index 00000000000..64b81c5f6f3 --- /dev/null +++ b/changelogs/fragments/47492-docker_network-add-ipv6-support.yaml @@ -0,0 +1,5 @@ +--- +minor_changes: + - "docker_network - Add support for IPv6 networks." +deprecated_features: + - "docker_network - Deprecate ``ipam_options`` in favour of ``ipam_config``." diff --git a/changelogs/fragments/docker_network-requirements.yaml b/changelogs/fragments/docker_network-requirements.yaml new file mode 100644 index 00000000000..c8c2df88356 --- /dev/null +++ b/changelogs/fragments/docker_network-requirements.yaml @@ -0,0 +1,4 @@ +--- +minor_changes: + - "docker_network - Minimum docker-py version increased from ``1.8.0`` to ``1.10.0``." + - "docker_network - Minimum docker server version increased from ``1.9.0`` to ``1.10.0``." diff --git a/lib/ansible/modules/cloud/docker/docker_network.py b/lib/ansible/modules/cloud/docker/docker_network.py index f619353cb60..15a654b2b60 100644 --- a/lib/ansible/modules/cloud/docker/docker_network.py +++ b/lib/ansible/modules/cloud/docker/docker_network.py @@ -62,6 +62,14 @@ options: aliases: - incremental + enable_ipv6: + version_added: 2.8 + description: + - Enable IPv6 networking. + type: bool + default: null + required: false + ipam_driver: description: - Specify an IPAM driver. @@ -69,6 +77,18 @@ options: ipam_options: description: - Dictionary of IPAM options. + - Deprecated in 2.8, will be removed in 2.12. Use parameter ``ipam_config`` instead. In Docker 1.10.0, IPAM + options were introduced (see L(here,https://github.com/moby/moby/pull/17316)). This module parameter addresses + the IPAM config not the newly introduced IPAM options. + + ipam_config: + version_added: 2.8 + description: + - List of IPAM config blocks. Consult + L(Docker docs,https://docs.docker.com/compose/compose-file/compose-file-v2/#ipam) for valid options and values. + type: list + default: null + required: false state: description: @@ -104,7 +124,7 @@ author: requirements: - "python >= 2.6" - - "docker-py >= 1.9.0" + - "docker-py >= 1.10.0" - "Please note that the L(docker-py,https://pypi.org/project/docker-py/) Python module has been superseded by L(docker,https://pypi.org/project/docker/) (see L(here,https://github.com/docker/docker-py/issues/1310) for details). @@ -113,7 +133,7 @@ requirements: be installed at the same time. Also note that when both modules are installed and one of them is uninstalled, the other might no longer function and a reinstall of it is required." - - "The docker server >= 1.9.0" + - "The docker server >= 1.10.0" ''' EXAMPLES = ''' @@ -141,15 +161,37 @@ EXAMPLES = ''' - container_a appends: yes -- name: Create a network with options +- name: Create a network with driver options docker_network: name: network_two driver_options: com.docker.network.bridge.name: net2 - ipam_options: - subnet: '172.3.26.0/16' - gateway: 172.3.26.1 - iprange: '192.168.1.0/24' + +- name: Create a network with custom IPAM config + docker_network: + name: network_three + ipam_config: + - subnet: 172.3.27.0/24 + gateway: 172.3.27.2 + iprange: 172.3.27.0/26 + aux_addresses: + host1: 172.3.27.3 + host2: 172.3.27.4 + +- name: Create a network with IPv6 IPAM config + docker_network: + name: network_ipv6_one + enable_ipv6: yes + ipam_config: + - subnet: fdd1:ac8c:0557:7ce1::/64 + +- name: Create a network with IPv6 and custom IPv4 IPAM config + docker_network: + name: network_ipv6_two + enable_ipv6: yes + ipam_config: + - subnet: 172.4.27.0/24 + - subnet: fdd1:ac8c:0557:7ce2::/64 - name: Delete a network, disconnecting all containers docker_network: @@ -166,6 +208,8 @@ facts: sample: {} ''' +import re + from ansible.module_utils.docker_common import AnsibleDockerClient, DockerBaseClass, HAS_DOCKER_PY_2, HAS_DOCKER_PY_3 try: @@ -189,10 +233,12 @@ class TaskParameters(DockerBaseClass): self.driver_options = None self.ipam_driver = None self.ipam_options = None + self.ipam_config = None self.appends = None self.force = None self.internal = None self.debug = None + self.enable_ipv6 = None for key, value in client.module.params.items(): setattr(self, key, value) @@ -202,6 +248,26 @@ def container_names_in_network(network): return [c['Name'] for c in network['Containers'].values()] if network['Containers'] else [] +CIDR_IPV4 = re.compile(r'^([0-9]{1,3}\.){3}[0-9]{1,3}/([0-9]|[1-2][0-9]|3[0-2])$') +CIDR_IPV6 = re.compile(r'^[0-9a-fA-F:]+/([0-9]|[1-9][0-9]|1[0-2][0-9])$') + + +def get_ip_version(cidr): + """Gets the IP version of a CIDR string + + :param cidr: Valid CIDR + :type cidr: str + :return: ``ipv4`` or ``ipv6`` + :rtype: str + :raises ValueError: If ``cidr`` is not a valid CIDR + """ + if CIDR_IPV4.match(cidr): + return 'ipv4' + elif CIDR_IPV6.match(cidr): + return 'ipv6' + raise ValueError('"{0}" is not a valid CIDR'.format(cidr)) + + class DockerNetworkManager(object): def __init__(self, client): @@ -219,6 +285,9 @@ class DockerNetworkManager(object): if not self.parameters.connected and self.existing_network: self.parameters.connected = container_names_in_network(self.existing_network) + if self.parameters.ipam_options: + self.parameters.ipam_config = [self.parameters.ipam_options] + state = self.parameters.state if state == 'present': self.present() @@ -253,29 +322,41 @@ class DockerNetworkManager(object): if not (key in net['Options']) or value != net['Options'][key]: different = True differences.append('driver_options.%s' % key) + if self.parameters.ipam_driver: if not net.get('IPAM') or net['IPAM']['Driver'] != self.parameters.ipam_driver: different = True differences.append('ipam_driver') - if self.parameters.ipam_options: - if not net.get('IPAM') or not net['IPAM'].get('Config'): + + if self.parameters.ipam_config is not None and self.parameters.ipam_config: + if not net.get('IPAM') or not net['IPAM']['Config']: different = True - differences.append('ipam_options') + differences.append('ipam_config') else: - for key, value in self.parameters.ipam_options.items(): - camelkey = None - for net_key in net['IPAM']['Config'][0]: - if key == net_key.lower(): - camelkey = net_key - break - if not camelkey: - # key not found - different = True - differences.append('ipam_options.%s' % key) - elif net['IPAM']['Config'][0].get(camelkey) != value: - # key has different value - different = True - differences.append('ipam_options.%s' % key) + for idx, ipam_config in enumerate(self.parameters.ipam_config): + net_config = dict() + try: + ip_version = get_ip_version(ipam_config['subnet']) + for net_ipam_config in net['IPAM']['Config']: + if ip_version == get_ip_version(net_ipam_config['Subnet']): + net_config = net_ipam_config + except ValueError as e: + self.client.fail(str(e)) + + for key, value in ipam_config.items(): + camelkey = None + for net_key in net_config: + if key == net_key.lower(): + camelkey = net_key + break + if not camelkey or net_config.get(camelkey) != value: + different = True + differences.append('ipam_config[%s].%s' % (idx, key)) + + if self.parameters.enable_ipv6 is not None and self.parameters.enable_ipv6 != net.get('EnableIPv6', False): + different = True + differences.append('enable_ipv6') + if self.parameters.internal is not None: if self.parameters.internal: if not net.get('Internal'): @@ -289,26 +370,33 @@ class DockerNetworkManager(object): def create_network(self): if not self.existing_network: + params = dict( + driver=self.parameters.driver, + options=self.parameters.driver_options, + ) + ipam_pools = [] - if self.parameters.ipam_options: - if HAS_DOCKER_PY_2 or HAS_DOCKER_PY_3: - ipam_pools.append(IPAMPool(**self.parameters.ipam_options)) - else: - ipam_pools.append(utils.create_ipam_pool(**self.parameters.ipam_options)) + if self.parameters.ipam_config: + for ipam_pool in self.parameters.ipam_config: + if HAS_DOCKER_PY_2 or HAS_DOCKER_PY_3: + ipam_pools.append(IPAMPool(**ipam_pool)) + else: + ipam_pools.append(utils.create_ipam_pool(**ipam_pool)) if HAS_DOCKER_PY_2 or HAS_DOCKER_PY_3: - ipam_config = IPAMConfig(driver=self.parameters.ipam_driver, - pool_configs=ipam_pools) + params['ipam'] = IPAMConfig(driver=self.parameters.ipam_driver, + pool_configs=ipam_pools) else: - ipam_config = utils.create_ipam_config(driver=self.parameters.ipam_driver, - pool_configs=ipam_pools) + params['ipam'] = utils.create_ipam_config(driver=self.parameters.ipam_driver, + pool_configs=ipam_pools) + + if self.parameters.enable_ipv6 is not None: + params['enable_ipv6'] = self.parameters.enable_ipv6 + if self.parameters.internal is not None: + params['internal'] = self.parameters.internal if not self.check_mode: - resp = self.client.create_network(self.parameters.network_name, - driver=self.parameters.driver, - options=self.parameters.driver_options, - ipam=ipam_config, - internal=self.parameters.internal) + resp = self.client.create_network(self.parameters.network_name, **params) self.existing_network = self.client.inspect_network(resp['Id']) self.results['actions'].append("Created network %s with driver %s" % (self.parameters.network_name, self.parameters.driver)) @@ -393,17 +481,24 @@ def main(): driver_options=dict(type='dict', default={}), force=dict(type='bool', default=False), appends=dict(type='bool', default=False, aliases=['incremental']), - ipam_driver=dict(type='str', default=None), - ipam_options=dict(type='dict', default={}), - internal=dict(type='bool', default=None), + ipam_driver=dict(type='str'), + ipam_options=dict(type='dict', default={}, removed_in_version='2.12'), + ipam_config=dict(type='list', elements='dict'), + enable_ipv6=dict(type='bool'), + internal=dict(type='bool'), debug=dict(type='bool', default=False) ) + mutually_exclusive = [ + ('ipam_config', 'ipam_options') + ] + client = AnsibleDockerClient( argument_spec=argument_spec, + mutually_exclusive=mutually_exclusive, supports_check_mode=True, - min_docker_version='1.9.0' - # "The docker server >= 1.9.0" + min_docker_version='1.10.0' + # "The docker server >= 1.10.0" ) cm = DockerNetworkManager(client) diff --git a/test/integration/targets/docker_network/tasks/tests/ipam.yml b/test/integration/targets/docker_network/tasks/tests/ipam.yml new file mode 100644 index 00000000000..993635f770a --- /dev/null +++ b/test/integration/targets/docker_network/tasks/tests/ipam.yml @@ -0,0 +1,212 @@ +--- +- name: Registering network names + set_fact: + nname_ipam_0: "{{ name_prefix ~ '-network-ipam-0' }}" + nname_ipam_1: "{{ name_prefix ~ '-network-ipam-1' }}" + nname_ipam_2: "{{ name_prefix ~ '-network-ipam-2' }}" + nname_ipam_3: "{{ name_prefix ~ '-network-ipam-3' }}" + +- name: Registering network names + set_fact: + dnetworks: "{{ dnetworks }} + [nname_ipam_0, nname_ipam_1, nname_ipam_2, nname_ipam_3]" + + +#################### network-ipam-0 deprecated #################### + +- name: Create network with ipam_config and deprecated ipam_options + docker_network: + name: "{{ nname_ipam_0 }}" + ipam_options: + subnet: 172.3.29.0/24 + ipam_config: + - subnet: 172.3.29.0/24 + register: network + ignore_errors: yes + +- assert: + that: + - network is failed + - "network.msg == 'parameters are mutually exclusive: ipam_config, ipam_options'" + +- name: Create network with deprecated custom IPAM config + docker_network: + name: "{{ nname_ipam_0 }}" + ipam_options: + subnet: 172.3.29.0/24 + register: network + +- assert: + that: + - network is changed + +- name: Change subnet of network with deprecated custom IPAM config + docker_network: + name: "{{ nname_ipam_0 }}" + ipam_options: + subnet: 172.3.30.0/24 + register: network + diff: yes + +- assert: + that: + - network is changed + - network['diff'] | length == 1 + - network['diff'][0] == "ipam_config[0].subnet" + +- name: Cleanup network with ipam_config and deprecated ipam_options + docker_network: + name: "{{ nname_ipam_0 }}" + state: absent + + +#################### network-ipam-1 #################### +- name: Create network with custom IPAM config + docker_network: + name: "{{ nname_ipam_1 }}" + ipam_config: + - subnet: 172.3.27.0/24 + gateway: 172.3.27.2 + iprange: 172.3.27.0/26 + aux_addresses: + host1: 172.3.27.3 + host2: 172.3.27.4 + register: network + +- assert: + that: + - network is changed + +- name: Change subnet, gateway, iprange and auxiliary addresses of network with custom IPAM config + docker_network: + name: "{{ nname_ipam_1 }}" + ipam_config: + - subnet: 172.3.28.0/24 + gateway: 172.3.28.2 + iprange: 172.3.28.0/26 + aux_addresses: + host1: 172.3.28.3 + register: network + diff: yes + +- assert: + that: + - network is changed + - network['diff'] | length == 4 + - '"ipam_config[0].subnet" in network["diff"]' + - '"ipam_config[0].gateway" in network["diff"]' + - '"ipam_config[0].iprange" in network["diff"]' + - '"ipam_config[0].aux_addresses" in network["diff"]' + +- name: Remove gateway and iprange of network with custom IPAM config + docker_network: + name: "{{ nname_ipam_1 }}" + ipam_config: + - subnet: 172.3.28.0/24 + register: network + +- assert: + that: + - network is not changed + +- name: Cleanup network with custom IPAM config + docker_network: + name: "{{ nname_ipam_1 }}" + state: absent + + +#################### network-ipam-2 #################### + +- name: Create network with IPv6 IPAM config + docker_network: + name: "{{ nname_ipam_2 }}" + enable_ipv6: yes + ipam_config: + - subnet: fdd1:ac8c:0557:7ce0::/64 + register: network + +- assert: + that: + - network is changed + +- name: Change subnet of network with IPv6 IPAM config + docker_network: + name: "{{ nname_ipam_2 }}" + enable_ipv6: yes + ipam_config: + - subnet: fdd1:ac8c:0557:7ce1::/64 + register: network + diff: yes + +- assert: + that: + - network is changed + - network['diff'] | length == 1 + - network['diff'][0] == "ipam_config[0].subnet" + +- name: Change subnet of network with IPv6 IPAM config + docker_network: + name: "{{ nname_ipam_2 }}" + enable_ipv6: yes + ipam_config: + - subnet: "fdd1:ac8c:0557:7ce1::" + register: network + ignore_errors: yes + +- assert: + that: + - network is failed + - "network.msg == '\"fdd1:ac8c:0557:7ce1::\" is not a valid CIDR'" + +- name: Cleanup network with IPv6 IPAM config + docker_network: + name: "{{ nname_ipam_2 }}" + state: absent + + +#################### network-ipam-3 #################### + +- name: Create network with IPv6 and custom IPv4 IPAM config + docker_network: + name: "{{ nname_ipam_3 }}" + enable_ipv6: yes + ipam_config: + - subnet: 172.4.27.0/24 + - subnet: fdd1:ac8c:0557:7ce2::/64 + register: network + +- assert: + that: + - network is changed + +- name: Change subnet order of network with IPv6 and custom IPv4 IPAM config + docker_network: + name: "{{ nname_ipam_3 }}" + enable_ipv6: yes + ipam_config: + - subnet: fdd1:ac8c:0557:7ce2::/64 + - subnet: 172.4.27.0/24 + register: network + +- assert: + that: + - network is not changed + +- name: Remove IPv6 from network with custom IPv4 and IPv6 IPAM config + docker_network: + name: "{{ nname_ipam_3 }}" + enable_ipv6: no + ipam_config: + - subnet: 172.4.27.0/24 + register: network + diff: yes + +- assert: + that: + - network is changed + - network['diff'] | length == 1 + - network['diff'][0] == "enable_ipv6" + +- name: Cleanup network with IPv6 and custom IPv4 IPAM config + docker_network: + name: "{{ nname_ipam_3 }}" + state: absent diff --git a/test/units/modules/cloud/docker/test_docker_network.py b/test/units/modules/cloud/docker/test_docker_network.py new file mode 100644 index 00000000000..2c385a71d29 --- /dev/null +++ b/test/units/modules/cloud/docker/test_docker_network.py @@ -0,0 +1,27 @@ +"""Unit tests for docker_network.""" +import pytest + +from ansible.modules.cloud.docker.docker_network import get_ip_version + + +@pytest.mark.parametrize("cidr,expected", [ + ('192.168.0.1/16', 'ipv4'), + ('192.168.0.1/24', 'ipv4'), + ('192.168.0.1/32', 'ipv4'), + ('fdd1:ac8c:0557:7ce2::/64', 'ipv6'), + ('fdd1:ac8c:0557:7ce2::/128', 'ipv6'), +]) +def test_get_ip_version_positives(cidr, expected): + assert get_ip_version(cidr) == expected + + +@pytest.mark.parametrize("cidr", [ + '192.168.0.1', + '192.168.0.1/34', + '192.168.0.1/asd', + 'fdd1:ac8c:0557:7ce2::', +]) +def test_get_ip_version_negatives(cidr): + with pytest.raises(ValueError) as e: + get_ip_version(cidr) + assert '"{0}" is not a valid CIDR'.format(cidr) == str(e.value)