Repository: ansible/ansible

Issue:
Docker port binding fails when both same UDP and TCP is bound
##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
`docker_container`

##### ANSIBLE VERSION
```
ansible 2.3.1.0
  config file = /home/doo/.ansible.cfg
  configured module search path = Default w/o overrides
  python version = 2.7.13 (default, Jun 26 2017, 10:20:05) [GCC 7.1.1 20170622 (Red Hat 7.1.1-3)]

```


##### OS / ENVIRONMENT
FC 26

##### SUMMARY
```
    published_ports:
       - "{{ ansible_docker0.ipv4.address }}:53:53"
       - "{{ ansible_docker0.ipv4.address }}:53:53/udp"
```
only binds TCP/53 and not both udp and tcp.

##### STEPS TO REPRODUCE
```yaml
- name: FML
  tags:
    - service
  docker_container:
    name: "osef"
    image: "httpd:2.4"
    pull: yes
    user: root:root
    memory: "128M"
    timeout: 10
    stop_timeout: 10
    restart_policy: "unless-stopped"
    restart: yes
    state: started
    published_ports:
       - "{{ ansible_docker0.ipv4.address }}:53:53"
       - "{{ ansible_docker0.ipv4.address }}:53:53/udp"

```

##### EXPECTED RESULTS
both UDP/53 and TCP/53 are bound.

##### ACTUAL RESULTS
`docker inspect foobar`

```json
            "Ports": {
                "53/tcp": [
                    {
                        "HostIp": "$docker0_ip_addr",
                        "HostPort": "53"
                    }
                ]
            },

```



Discussion:
cc @ThomasSteinbach @cove @dusdanig @jctanner @joshuaconner @kassiansun @softzilla @zfil
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
@theonlydoo This works for me with `docker-py 2.4.2`, could you please upgrade it and try again?
```
root@foo:~ $ pip freeze|grep -i docker
docker-py==1.10.6
docker-pycreds==0.2.1
root@foo:~ $ pip install docker-py==2.4.2
Collecting docker-py==2.4.2
  Could not find a version that satisfies the requirement docker-py==2.4.2 (from versions: 0.0.4, 0.0.5, 0.0.6, 0.1.0, 0.1.1, 0.1.2, 0.1.3, 0.1.4, 0.1.5, 0.2.0, 0.2.1, 0.2.2, 0.2.3, 0.3.0, 0.3.1, 0.3.2, 0.4.0, 0.5.0, 0.5.1, 0.5.2, 0.5.3, 0.6.0, 0.7.0, 0.7.1, 0.7.2, 1.0.0, 1.1.0, 1.2.1, 1.2.2, 1.2.3rc1, 1.2.3, 1.3.0, 1.3.1, 1.4.0, 1.5.0, 1.6.0, 1.7.0rc1, 1.7.0rc2, 1.7.0rc3, 1.7.0, 1.7.1, 1.7.2, 1.8.0rc2, 1.8.0rc4, 1.8.0rc5, 1.8.0, 1.8.1, 1.9.0rc1, 1.9.0rc2, 1.9.0, 1.10.0, 1.10.1, 1.10.2, 1.10.3, 1.10.4, 1.10.5, 1.10.6)
No matching distribution found for docker-py==2.4.2
```

I tried with the latest version of docker-py and had the same result
@theonlydoo `pip install docker`, it was renamed to `docker` since version 2.0
same result:

```bash
$ pip freeze|grep -i docker
docker==2.4.2
docker-pycreds==0.2.1
```

```json
      "Ports": {
        "53/tcp": [
          {
            "HostIp": "$docker0_ip_addr",
            "HostPort": "53"
          }
        ]
      },
```
cc @chouseknecht
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->

Resolving patch:
diff --git a/lib/ansible/modules/cloud/docker/docker_container.py b/lib/ansible/modules/cloud/docker/docker_container.py
--- a/lib/ansible/modules/cloud/docker/docker_container.py
+++ b/lib/ansible/modules/cloud/docker/docker_container.py
@@ -1019,6 +1019,8 @@ def _parse_exposed_ports(self, published_ports):
                     protocol = 'tcp'
                     port = int(publish_port)
                 for exposed_port in exposed:
+                    if exposed_port[1] != protocol:
+                        continue
                     if isinstance(exposed_port[0], string_types) and '-' in exposed_port[0]:
                         start_port, end_port = exposed_port[0].split('-')
                         if int(start_port) <= port <= int(end_port):
@@ -1210,7 +1212,7 @@ def has_different_configuration(self, image):
 
         # "ExposedPorts": null returns None type & causes AttributeError - PR #5517
         if config.get('ExposedPorts') is not None:
-            expected_exposed = [re.sub(r'/.+$', '', p) for p in config.get('ExposedPorts', dict()).keys()]
+            expected_exposed = [self._normalize_port(p) for p in config.get('ExposedPorts', dict()).keys()]
         else:
             expected_exposed = []
 
@@ -1625,10 +1627,10 @@ def _get_expected_exposed(self, image):
         self.log('_get_expected_exposed')
         image_ports = []
         if image:
-            image_ports = [re.sub(r'/.+$', '', p) for p in (image['ContainerConfig'].get('ExposedPorts') or {}).keys()]
+            image_ports = [self._normalize_port(p) for p in (image['ContainerConfig'].get('ExposedPorts') or {}).keys()]
         param_ports = []
         if self.parameters.ports:
-            param_ports = [str(p[0]) for p in self.parameters.ports]
+            param_ports = [str(p[0]) + '/' + p[1] for p in self.parameters.ports]
         result = list(set(image_ports + param_ports))
         self.log(result, pretty_print=True)
         return result
@@ -1669,6 +1671,11 @@ def _convert_simple_dict_to_list(self, param_name, join_with=':'):
             results.append("%s%s%s" % (key, join_with, value))
         return results
 
+    def _normalize_port(self, port):
+        if '/' not in port:
+            return port + '/tcp'
+        return port
+
 
 class ContainerManager(DockerBaseClass):
     '''


Test patch:
diff --git a/test/units/modules/cloud/docker/__init__.py b/test/units/modules/cloud/docker/__init__.py
new file mode 100644
diff --git a/test/units/modules/cloud/docker/test_docker_container.py b/test/units/modules/cloud/docker/test_docker_container.py
new file mode 100644
--- /dev/null
+++ b/test/units/modules/cloud/docker/test_docker_container.py
@@ -0,0 +1,19 @@
+import unittest
+
+from ansible.modules.cloud.docker.docker_container import TaskParameters
+
+
+class TestTaskParameters(unittest.TestCase):
+    """Unit tests for TaskParameters."""
+
+    def test_parse_exposed_ports_tcp_udp(self):
+        """
+        Ensure _parse_exposed_ports does not cancel ports with the same
+        number but different protocol.
+        """
+        task_params = TaskParameters.__new__(TaskParameters)
+        task_params.exposed_ports = None
+        result = task_params._parse_exposed_ports([80, '443', '443/udp'])
+        self.assertTrue((80, 'tcp') in result)
+        self.assertTrue((443, 'tcp') in result)
+        self.assertTrue((443, 'udp') in result)