[docker_network] add ipv6 support (#47492)

* [docker_network] add ipv6 support

* docker_network: review ipam_options

* docker_network: fix requirements

* docker_network: fix deprecation notice

* docker_network: add minimum docker version change

* docker_network: remove trailing whitespace

* docker_network: revert rename of network_four #discussion_r228707101

* docker_network: refactor IPAM config comparison #discussion_r228707255, #discussion_r228707280

* docker_network: correct spelling of IPv4 and IPv6 #discussion_r228707114, #discussion_r228707138

* docker_network: manually remove networks #discussion_r228709051

* docker_network: refactor enable_ipv6 condition #discussion_r228707317

* docker_network: add mutually_exclusive #discussion_r228707185

* docker_network: fix iprange #discussion_r228709072

* docker_network: add auxiliary addresses in examples and tests

* docker_network: link to docker docs #discussion_r228707018

* docker_network: remove list default #discussion_r228707060, #discussion_r228709091

* docker_network: introduce params syntax for create_network() #discussion_r228709031

* docker_network: beautify code

* docker_network: resolve change requests

* docker_network: add yaml header

* docker_networking: fix get_ip_version

* docker_network: extend CIDR test

* docker_network: use backported unittest2 for python 2.6

* docker_network: migrate unittest to pytest
This commit is contained in:
Stephan Müller 2018-11-01 23:59:16 +01:00 committed by Will Thames
parent 80ca779aa7
commit 00bab2d24d
6 changed files with 386 additions and 44 deletions

View file

@ -1,4 +1,3 @@
--- ---
minor_changes: minor_changes:
- "docker_network - ``internal`` is now used to set the ``Internal`` property of the docker network during creation." - "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``."

View file

@ -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``."

View file

@ -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``."

View file

@ -62,6 +62,14 @@ options:
aliases: aliases:
- incremental - incremental
enable_ipv6:
version_added: 2.8
description:
- Enable IPv6 networking.
type: bool
default: null
required: false
ipam_driver: ipam_driver:
description: description:
- Specify an IPAM driver. - Specify an IPAM driver.
@ -69,6 +77,18 @@ options:
ipam_options: ipam_options:
description: description:
- Dictionary of IPAM options. - 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: state:
description: description:
@ -104,7 +124,7 @@ author:
requirements: requirements:
- "python >= 2.6" - "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 - "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/) 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). (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 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 and one of them is uninstalled, the other might no longer function and a
reinstall of it is required." reinstall of it is required."
- "The docker server >= 1.9.0" - "The docker server >= 1.10.0"
''' '''
EXAMPLES = ''' EXAMPLES = '''
@ -141,15 +161,37 @@ EXAMPLES = '''
- container_a - container_a
appends: yes appends: yes
- name: Create a network with options - name: Create a network with driver options
docker_network: docker_network:
name: network_two name: network_two
driver_options: driver_options:
com.docker.network.bridge.name: net2 com.docker.network.bridge.name: net2
ipam_options:
subnet: '172.3.26.0/16' - name: Create a network with custom IPAM config
gateway: 172.3.26.1 docker_network:
iprange: '192.168.1.0/24' 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 - name: Delete a network, disconnecting all containers
docker_network: docker_network:
@ -166,6 +208,8 @@ facts:
sample: {} sample: {}
''' '''
import re
from ansible.module_utils.docker_common import AnsibleDockerClient, DockerBaseClass, HAS_DOCKER_PY_2, HAS_DOCKER_PY_3 from ansible.module_utils.docker_common import AnsibleDockerClient, DockerBaseClass, HAS_DOCKER_PY_2, HAS_DOCKER_PY_3
try: try:
@ -189,10 +233,12 @@ class TaskParameters(DockerBaseClass):
self.driver_options = None self.driver_options = None
self.ipam_driver = None self.ipam_driver = None
self.ipam_options = None self.ipam_options = None
self.ipam_config = None
self.appends = None self.appends = None
self.force = None self.force = None
self.internal = None self.internal = None
self.debug = None self.debug = None
self.enable_ipv6 = None
for key, value in client.module.params.items(): for key, value in client.module.params.items():
setattr(self, key, value) 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 [] 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): class DockerNetworkManager(object):
def __init__(self, client): def __init__(self, client):
@ -219,6 +285,9 @@ class DockerNetworkManager(object):
if not self.parameters.connected and self.existing_network: if not self.parameters.connected and self.existing_network:
self.parameters.connected = container_names_in_network(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 state = self.parameters.state
if state == 'present': if state == 'present':
self.present() self.present()
@ -253,29 +322,41 @@ class DockerNetworkManager(object):
if not (key in net['Options']) or value != net['Options'][key]: if not (key in net['Options']) or value != net['Options'][key]:
different = True different = True
differences.append('driver_options.%s' % key) differences.append('driver_options.%s' % key)
if self.parameters.ipam_driver: if self.parameters.ipam_driver:
if not net.get('IPAM') or net['IPAM']['Driver'] != self.parameters.ipam_driver: if not net.get('IPAM') or net['IPAM']['Driver'] != self.parameters.ipam_driver:
different = True different = True
differences.append('ipam_driver') 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 different = True
differences.append('ipam_options') differences.append('ipam_config')
else: else:
for key, value in self.parameters.ipam_options.items(): for idx, ipam_config in enumerate(self.parameters.ipam_config):
camelkey = None net_config = dict()
for net_key in net['IPAM']['Config'][0]: try:
if key == net_key.lower(): ip_version = get_ip_version(ipam_config['subnet'])
camelkey = net_key for net_ipam_config in net['IPAM']['Config']:
break if ip_version == get_ip_version(net_ipam_config['Subnet']):
if not camelkey: net_config = net_ipam_config
# key not found except ValueError as e:
different = True self.client.fail(str(e))
differences.append('ipam_options.%s' % key)
elif net['IPAM']['Config'][0].get(camelkey) != value: for key, value in ipam_config.items():
# key has different value camelkey = None
different = True for net_key in net_config:
differences.append('ipam_options.%s' % key) 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 is not None:
if self.parameters.internal: if self.parameters.internal:
if not net.get('Internal'): if not net.get('Internal'):
@ -289,26 +370,33 @@ class DockerNetworkManager(object):
def create_network(self): def create_network(self):
if not self.existing_network: if not self.existing_network:
params = dict(
driver=self.parameters.driver,
options=self.parameters.driver_options,
)
ipam_pools = [] ipam_pools = []
if self.parameters.ipam_options: if self.parameters.ipam_config:
if HAS_DOCKER_PY_2 or HAS_DOCKER_PY_3: for ipam_pool in self.parameters.ipam_config:
ipam_pools.append(IPAMPool(**self.parameters.ipam_options)) if HAS_DOCKER_PY_2 or HAS_DOCKER_PY_3:
else: ipam_pools.append(IPAMPool(**ipam_pool))
ipam_pools.append(utils.create_ipam_pool(**self.parameters.ipam_options)) else:
ipam_pools.append(utils.create_ipam_pool(**ipam_pool))
if HAS_DOCKER_PY_2 or HAS_DOCKER_PY_3: if HAS_DOCKER_PY_2 or HAS_DOCKER_PY_3:
ipam_config = IPAMConfig(driver=self.parameters.ipam_driver, params['ipam'] = IPAMConfig(driver=self.parameters.ipam_driver,
pool_configs=ipam_pools) pool_configs=ipam_pools)
else: else:
ipam_config = utils.create_ipam_config(driver=self.parameters.ipam_driver, params['ipam'] = utils.create_ipam_config(driver=self.parameters.ipam_driver,
pool_configs=ipam_pools) 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: if not self.check_mode:
resp = self.client.create_network(self.parameters.network_name, resp = self.client.create_network(self.parameters.network_name, **params)
driver=self.parameters.driver,
options=self.parameters.driver_options,
ipam=ipam_config,
internal=self.parameters.internal)
self.existing_network = self.client.inspect_network(resp['Id']) 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)) 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={}), driver_options=dict(type='dict', default={}),
force=dict(type='bool', default=False), force=dict(type='bool', default=False),
appends=dict(type='bool', default=False, aliases=['incremental']), appends=dict(type='bool', default=False, aliases=['incremental']),
ipam_driver=dict(type='str', default=None), ipam_driver=dict(type='str'),
ipam_options=dict(type='dict', default={}), ipam_options=dict(type='dict', default={}, removed_in_version='2.12'),
internal=dict(type='bool', default=None), ipam_config=dict(type='list', elements='dict'),
enable_ipv6=dict(type='bool'),
internal=dict(type='bool'),
debug=dict(type='bool', default=False) debug=dict(type='bool', default=False)
) )
mutually_exclusive = [
('ipam_config', 'ipam_options')
]
client = AnsibleDockerClient( client = AnsibleDockerClient(
argument_spec=argument_spec, argument_spec=argument_spec,
mutually_exclusive=mutually_exclusive,
supports_check_mode=True, supports_check_mode=True,
min_docker_version='1.9.0' min_docker_version='1.10.0'
# "The docker server >= 1.9.0" # "The docker server >= 1.10.0"
) )
cm = DockerNetworkManager(client) cm = DockerNetworkManager(client)

View file

@ -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

View file

@ -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)