From a74774488d085590a7d930b8670fcf5d3fdd3f73 Mon Sep 17 00:00:00 2001 From: Felix Fontein Date: Mon, 8 Oct 2018 11:53:24 +0200 Subject: [PATCH] docker_container: add port range and IPv6 support for published_ports (#46596) * Allow port ranges. * Adding IPv6 support for published_ports. * Die when hostname is passed instead of IP address. * Added changelog. --- ...46596-docker_container-published_ports.yml | 2 + .../modules/cloud/docker/docker_container.py | 89 +++++++++++-- .../docker_container/tasks/tests/ports.yml | 123 ++++++++++++++++++ 3 files changed, 200 insertions(+), 14 deletions(-) create mode 100644 changelogs/fragments/46596-docker_container-published_ports.yml diff --git a/changelogs/fragments/46596-docker_container-published_ports.yml b/changelogs/fragments/46596-docker_container-published_ports.yml new file mode 100644 index 00000000000..5c239b7a2de --- /dev/null +++ b/changelogs/fragments/46596-docker_container-published_ports.yml @@ -0,0 +1,2 @@ +minor_changes: +- "docker_container - published_ports now supports port ranges, IPv6 addresses, and no longer accepts hostnames, which were never used correctly anyway." diff --git a/lib/ansible/modules/cloud/docker/docker_container.py b/lib/ansible/modules/cloud/docker/docker_container.py index 8c2ecbbc09c..2fb2c948f6d 100644 --- a/lib/ansible/modules/cloud/docker/docker_container.py +++ b/lib/ansible/modules/cloud/docker/docker_container.py @@ -301,6 +301,11 @@ options: - List of ports to publish from the container to the host. - "Use docker CLI syntax: C(8000), C(9000:8000), or C(0.0.0.0:9000:8000), where 8000 is a container port, 9000 is a host port, and 0.0.0.0 is a host interface." + - Port ranges can be used for source and destination ports. If two ranges with + different lengths are specified, the shorter range will be used. + - "Bind addresses must be either IPv4 or IPv6 addresses. Hostnames are I(not) allowed. This + is different from the C(docker) command line utility. Use the L(dig lookup,../lookup/dig.html) + to resolve hostnames." - Container ports must be exposed either in the Dockerfile or via the C(expose) option. - A value of C(all) will publish all exposed container ports to random host ports, ignoring any other mappings. @@ -736,6 +741,51 @@ def is_volume_permissions(input): return True +def parse_port_range(range_or_port, module): + ''' + Parses a string containing either a single port or a range of ports. + + Returns a list of integers for each port in the list. + ''' + if '-' in range_or_port: + start, end = [int(port) for port in range_or_port.split('-')] + if end < start: + module.fail_json(msg='Invalid port range: {0}'.format(range_or_port)) + return list(range(start, end + 1)) + else: + return [int(range_or_port)] + + +def split_colon_ipv6(input, module): + ''' + Split string by ':', while keeping IPv6 addresses in square brackets in one component. + ''' + if '[' not in input: + return input.split(':') + start = 0 + result = [] + while start < len(input): + i = input.find('[', start) + if i < 0: + result.extend(input[start:].split(':')) + break + j = input.find(']', i) + if j < 0: + module.fail_json(msg='Cannot find closing "]" in input "{0}" for opening "[" at index {1}!'.format(input, i + 1)) + result.extend(input[start:i].split(':')) + k = input.find(':', j) + if k < 0: + result[-1] += input[i:] + start = len(input) + else: + result[-1] += input[i:k] + if k == len(input): + result.append('') + break + start = k + 1 + return result + + class TaskParameters(DockerBaseClass): ''' Access and parse module parameters @@ -1107,27 +1157,38 @@ class TaskParameters(DockerBaseClass): binds = {} for port in self.published_ports: - parts = str(port).split(':') + parts = split_colon_ipv6(str(port), self.client.module) container_port = parts[-1] - if '/' not in container_port: - container_port = int(parts[-1]) + protocol = '' + if '/' in container_port: + container_port, protocol = parts[-1].split('/') + container_ports = parse_port_range(container_port, self.client.module) p_len = len(parts) if p_len == 1: - bind = (default_ip,) + port_binds = len(container_ports) * [(default_ip,)] elif p_len == 2: - bind = (default_ip, int(parts[0])) + port_binds = [(default_ip, port) for port in parse_port_range(parts[0], self.client.module)] elif p_len == 3: - bind = (parts[0], int(parts[1])) if parts[1] else (parts[0],) - - if container_port in binds: - old_bind = binds[container_port] - if isinstance(old_bind, list): - old_bind.append(bind) + # We only allow IPv4 and IPv6 addresses for the bind address + if not re.match(r'^[0-9]+\.[0-9]+\.[0-9]+\.[0-9]+$', parts[0]) and not re.match(r'^\[[0-9a-fA-F:]+\]$', parts[0]): + self.fail(('Bind addresses for published ports must be IPv4 or IPv6 addresses, not hostnames. ' + 'Use the dig lookup to resolve hostnames. (Found hostname: {0})').format(parts[0])) + if parts[1]: + port_binds = [(parts[0], port) for port in parse_port_range(parts[1], self.client.module)] else: - binds[container_port] = [binds[container_port], bind] - else: - binds[container_port] = bind + port_binds = len(container_ports) * [(parts[0],)] + + for bind, container_port in zip(port_binds, container_ports): + idx = '{0}/{1}'.format(container_port, protocol) if protocol else container_port + if idx in binds: + old_bind = binds[idx] + if isinstance(old_bind, list): + old_bind.append(bind) + else: + binds[idx] = [old_bind, bind] + else: + binds[idx] = bind return binds def _get_volume_binds(self, volumes): diff --git a/test/integration/targets/docker_container/tasks/tests/ports.yml b/test/integration/targets/docker_container/tasks/tests/ports.yml index cc1af9d3ecc..2bc8e2b6fc4 100644 --- a/test/integration/targets/docker_container/tasks/tests/ports.yml +++ b/test/integration/targets/docker_container/tasks/tests/ports.yml @@ -92,3 +92,126 @@ - published_ports_3 is changed - published_ports_4 is not changed - published_ports_5 is changed + +#################################################################### +## published_ports: port range ##################################### +#################################################################### + +- name: published_ports -- port range + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + exposed_ports: + - 8080 + - 5000-5040 + published_ports: + - 8080:8080 + - 5000-5040:5000-5040 + stop_timeout: 1 + register: published_ports_1 + +- name: published_ports -- port range (idempotency) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + exposed_ports: + - 8080 + - 5000-5040 + published_ports: + - 8080:8080 + - 5000-5040:5000-5040 + stop_timeout: 1 + register: published_ports_2 + +- name: published_ports -- port range (different range) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + exposed_ports: + - 8080 + - 5000-5040 + published_ports: + - 8080:8080 + - 5010-5050:5010-5050 + stop_timeout: 1 + register: published_ports_3 + +- name: cleanup + docker_container: + name: "{{ cname }}" + state: absent + stop_timeout: 1 + +- assert: + that: + - published_ports_1 is changed + - published_ports_2 is not changed + - published_ports_3 is changed + +#################################################################### +## published_ports: IPv6 addresses ################################# +#################################################################### + +- name: published_ports -- IPv6 + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + published_ports: + - "[::1]:8080:8080" + stop_timeout: 1 + register: published_ports_1 + +- name: published_ports -- IPv6 (idempotency) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + published_ports: + - "[::1]:8080:8080" + stop_timeout: 1 + register: published_ports_2 + +- name: published_ports -- IPv6 (different IP) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + published_ports: + - 127.0.0.1:8080:8080 + stop_timeout: 1 + register: published_ports_3 + +- name: published_ports -- IPv6 (hostname) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + published_ports: + - localhost:8080:8080 + stop_timeout: 1 + register: published_ports_4 + ignore_errors: yes + +- name: cleanup + docker_container: + name: "{{ cname }}" + state: absent + stop_timeout: 1 + +- assert: + that: + - published_ports_1 is changed + - published_ports_2 is not changed + - published_ports_3 is changed + - published_ports_4 is failed