Repository: ansible/ansible-modules-core

Issue:
Docker port ip address defaults to 0.0.0.0 when network present
##### ISSUE TYPE
- Bug Report
##### COMPONENT NAME

_docker
##### ANSIBLE VERSION

```
$ ansible --version
ansible 2.0.2.0
  config file = /etc/ansible/ansible.cfg
  configured module search path = Default w/o overrides
```
##### CONFIGURATION

N/A
##### OS / ENVIRONMENT

N/A
##### SUMMARY

An unspecified ip address in "ports" defaults to "0.0.0.0" even if "net" is specified.
##### STEPS TO REPRODUCE

Create a docker bridge network with an ipv4 address:
`docker network create -o com.docker.network.bridge.host_binding_ipv4=10.1.1.1 test_network`

Start a container on the network:

```
- name: Start a docker container
  docker:
    name: registry
    image: docker.io/registry:2
    net: test_network
    ports:
      - '5000:5000'
    state: started
  become: yes
```
##### EXPECTED RESULTS

`docker inspect registry` should reveal that the container binds 5000 to 10.1.1.1:5000 .
##### ACTUAL RESULTS

`docker inspect registry` reveals that the container binds 5000 to 0.0.0.0:5000 .

Unfortunately, I'm not familiar enough with the docker api to fix this one.



Discussion:
It _may_ be sufficient to change `get_port_bindings` to use `None` instead of `'0.0.0.0'`, but I'm not sure how to test all of the implications.

FYI @chouseknecht 

This is quite nasty bug actually and I've just hit into it.

I have a server with two interfaces. The first interface `eth0` is connected to Internet and has a real IP address (`EXT` below), another one `eth1` is connected to the internal network and has an C class IP address.

My goal is to bind Docker containers to the internal interface only, they should not be visible from the outside world. So I run my Docker daemon with `--ip EXT` and it's sufficient to start containers with simple `docker run -d p 8080:80 ....` in order to bind to the internal network only.

But Ansible behavior is inconsistent with the command line here. When I specify `- "8080:80"` in a docker module, my container binds to `0.0.0.0:8080:80`, i.e. all the interfaces.

I'm guessing we have the same issue with docker_container. Will take a look. There must be a way to query the daemon and figure out the correct _default_ IP address. Will take a look.

I think there are two problems here:
- The module documentation says explicitly "Use docker 'CLI-style syntax", which could be interpreted  as "use the same syntax as in Docker CLI and get the same result, because it's passed plainly" (and I thought so).
- But in fact the module is too smart and performs implicit post-processing, it tries to "normalize" the `ports` specification, i.e. prepends _default_ IP address or appends `/tcp` by default (if I got it right from the sources).

If I may suggest, I would say I'd prefer completely explicit behavior, so that it's **me** who controls things and I can be sure that no post-processing occurs.

Trying to decide what to do with existing containers. Unfortunately there is no way to see the value of `--IP` passed to the Docker daemon. It doesn't seem to be part of `docker info`.

What you're requesting is for the module to accept whatever IP address is assigned by the daemon and ignore the IP address when evaluating existing containers. However, I think this causes a problem. For example, assume the daemon is assigned to public IP 45.10.1.100. If I run a task that creates container X with port binding 172.1.10.20:8000:80 (explicitly binding to an internal IP) and then run a subsequent task that creates the same container X with port binding 8000:80, the second task would not recreate the container when in fact it should.

Maybe a different way to fix this is to allow overriding the default IP address with a parameter and an environment variable?

@chouseknecht Are you saying that it doesn't seem possible to detect whether an IP address assigned to a container X matches a port binding specification? And querying the daemon was a bust?

A few more possibilities:
1) Make `8000:80` actually mean `any:8000:80`, or "I want to bind 8000 to 80 on some ip address", which is equivalent to the CLI style syntax, but does not update existing container ip address bindings.
2) Break backwards compatibility and the CLI style syntax and reject `8000:80`, requiring explicit ip addresses and/or a magic value (like `any`).
3) Have an "update=true" flag that keeps the behavior as it stands, and throws an error if the module is called with `8000:80`. If you change the flag to `false`, no updates are made, and only the container name is matched.

FWIW this seems to be a manifestation of not distinguishing between "update" and "present" in docker_container. The new module doesn't allow for the pattern of "create a container in one play and start it in another".

I'm saying that the docker_container module is overly complex already. Is there any reason the app could not be described in compose and then managed via the docker_service module?

@chouseknecht Yes, I think that that most apps, or at least certainly mine, could be described in compose. Do you intend to deprecate `docker_container` in favor of `docker_service` and push these decisions onto the compose folks? I'd like to know where to invest my energy.

In my opinion, `docker_container` is only incidentally complex because it attempts to use one module to describe everything one might want to do with a container instead of breaking it out into independent steps.

As examples, started/stopped/restarted should be different from present/absent, as started/stopped/restarted shouldn't specify the configuration of the container. Volume creation and removal doesn't belong. Neither do any opinionated defaults not being set upstream (the root cause of this issue... we set `"HostIP": "0.0.0.0"` in absence of user-specified ipv4 but docker-py accepts `None`. We compare "0.0.0.0" which is a valid IP instead of seeing we didn't specify the ip address and accepting whatever is present.).

I think we could clean all of that up... or even, I think I could clean that up... but now that complicated edge cases have been shipped, any change would be breaking, and I don't want to do work that ends up in a wasteland.

Following up... If I do the following, will it be merged?

1) Make `8000:80` actually mean `any:8000:80`, or "I want to bind 8000 to 80 on some ip address", which is equivalent to the CLI style syntax, but does not update existing container ip address bindings.

@cove, @softzilla, @zfil, @ThomasSteinbach, ping. This issue is still waiting on your response.
[click here for bot help](https://github.com/jctanner/ansibullbot/blob/ISSUE_TRIAGE_CLASS/ISSUE_HELP.md)

<!--- boilerplate: issue_renotify_maintainer --->


Resolving patch:
diff --git a/cloud/docker/docker_container.py b/cloud/docker/docker_container.py
--- a/cloud/docker/docker_container.py
+++ b/cloud/docker/docker_container.py
@@ -298,6 +298,12 @@
       - Container ports must be exposed either in the Dockerfile or via the C(expose) option.
       - A value of ALL will publish all exposed container ports to random host ports, ignoring
         any other mappings.
+      - If C(networks) parameter is provided, will inspect each network to see if there exists
+        a bridge network with optional parameter com.docker.network.bridge.host_binding_ipv4.
+        If such a network is found, then published ports where no host IP address is specified 
+        will be bound to the host IP pointed to by com.docker.network.bridge.host_binding_ipv4.
+        Note that the first bridge network with a com.docker.network.bridge.host_binding_ipv4 
+        value encountered in the list of C(networks) is the one that will be used. 
     aliases:
       - ports
     required: false
@@ -916,6 +922,20 @@ def _host_config(self):
 
         return self.client.create_host_config(**params)
 
+    @property
+    def default_host_ip(self):
+        ip = '0.0.0.0'
+        if not self.networks:
+            return ip
+        for net in self.networks:
+            if net.get('name'):
+                network = self.client.inspect_network(net['name'])
+                if network.get('Driver') == 'bridge' and \
+                   network.get('Options', {}).get('com.docker.network.bridge.host_binding_ipv4'):
+                    ip = network['Options']['com.docker.network.bridge.host_binding_ipv4']
+                    break
+        return ip
+
     def _parse_publish_ports(self):
         '''
         Parse ports from docker CLI syntax
@@ -926,6 +946,8 @@ def _parse_publish_ports(self):
         if 'all' in self.published_ports:
             return 'all'
 
+        default_ip = self.default_host_ip
+
         binds = {}
         for port in self.published_ports:
             parts = str(port).split(':')
@@ -935,9 +957,9 @@ def _parse_publish_ports(self):
 
             p_len = len(parts)
             if p_len == 1:
-                bind = ('0.0.0.0',)
+                bind = (default_ip,)
             elif p_len == 2:
-                bind = ('0.0.0.0', int(parts[0]))
+                bind = (default_ip, int(parts[0]))
             elif p_len == 3:
                 bind = (parts[0], int(parts[1])) if parts[1] else (parts[0],)
 
@@ -1413,7 +1435,7 @@ def _get_expected_ports(self):
             elif isinstance(config[0], tuple):
                 expected_bound_ports[container_port] = []
                 for host_ip, host_port in config:
-                    expected_bound_ports[container_port].append({ 'HostIp': host_ip, 'HostPort': str(host_port)})
+                    expected_bound_ports[container_port].append({'HostIp': host_ip, 'HostPort': str(host_port)})
             else:
                 expected_bound_ports[container_port] = [{'HostIp': config[0], 'HostPort': str(config[1])}]
         return expected_bound_ports


Test patch: