Repository: ansible/ansible-modules-core

Issue:
docker_container.py fails on second run if devices are configured
<!--- Verify first that your issue/request is not already reported in GitHub -->
##### ISSUE TYPE
- Bug Report
##### COMPONENT NAME

docker_container
##### ANSIBLE VERSION

<!--- Paste verbatim output from “ansible --version” between quotes below -->

```
ansible 2.2.0
  config file =
  configured module search path = Default w/o overrides
```
##### CONFIGURATION

Nothing special
##### OS / ENVIRONMENT

ubuntu
##### SUMMARY

When you have devices configured in your playbook, e.g.
    devices:
      - /dev/net/tun
      - /dev/kvm

Then a second run through of a playbook will fail.  Details below.
##### STEPS TO REPRODUCE

Create a container with devices configured.  Then rerun the same playbook.
##### EXPECTED RESULTS

Nothing should change, let alone fail.
##### ACTUAL RESULTS

<!--- What actually happened? If possible run with extra verbosity (-vvvv) -->

<!--- Paste verbatim command output between quotes below -->

```
An exception occurred during task execution. The full traceback is:
Traceback (most recent call last):
  File "/tmp/ansible_cxlQ9b/ansible_module_docker_container.py", line 1963, in <module>
    main()
  File "/tmp/ansible_cxlQ9b/ansible_module_docker_container.py", line 1956, in main
    cm = ContainerManager(client)
  File "/tmp/ansible_cxlQ9b/ansible_module_docker_container.py", line 1609, in __init__
    self.present(state)
  File "/tmp/ansible_cxlQ9b/ansible_module_docker_container.py", line 1634, in present
    different, differences = container.has_different_configuration(image)
  File "/tmp/ansible_cxlQ9b/ansible_module_docker_container.py", line 1253, in has_different_configuration
    set_b = set(value)
TypeError: unhashable type: 'dict'
failed: [localhost] (item={u'pa': None, u'container_name': u'pcontainer', u'container_version': u'latest', u'name': u'pa'}) => {
    "failed": true,
    "invocation": {
        "module_name": "docker_container"
    },
    "item": {
        "container_name": "pcontainer",
        "container_version": "latest",
        "name": "pa",
        "pa": null
    },
    "module_stderr": "Traceback (most recent call last):\n  File \"/tmp/ansible_cxlQ9b/ansible_module_docker_container.py\", line 1963, in <module>\n    main()\n  File \"/tmp/ansible_cxlQ9b/ansible_module_docker_container.py\", line 1956, in main\n    cm = ContainerManager(client)\n  File \"/tmp/ansible_cxlQ9b/ansible_module_docker_container.py\", line 1609, in __init__\n    self.present(state)\n  File \"/tmp/ansible_cxlQ9b/ansible_module_docker_container.py\", line 1634, in present\n    different, differences = container.has_different_configuration(image)\n  File \"/tmp/ansible_cxlQ9b/ansible_module_docker_container.py\", line 1253, in has_different_configuration\n    set_b = set(value)\nTypeError: unhashable type: 'dict'\n",
    "module_stdout": "",
    "msg": "MODULE FAILURE"
}

```

Note that the reason is because the docker inspect version of devices is different from the way devices are configured.  Devices are configured with a list of strings, each string like "/dev/on/host:/dev/in/container:rwm"  where rwm represents permissions.  Docker inspect returns something like this:

```
                    "Devices": [
                        {
                            "CgroupPermissions": "rwm",
                            "PathInContainer": "/dev/net/tun",
                            "PathOnHost": "/dev/net/tun"
                        },
                        {
                            "CgroupPermissions": "rwm",
                            "PathInContainer": "/dev/kvm",
                            "PathOnHost": "/dev/kvm"
                        }
                    ],
```

I have managed to make it work with this patch to lib/ansible/modules/core/cloud/docker/docker_container.py

```
1203c1203
<             devices=host_config.get('Devices'),

---
>             devices=[':'.join([ t[p] for p in ['PathOnHost','PathInContainer' if t['PathInContainer'] != t['PathOnHost'] else None ,'CgroupPermissions' if t['CgroupPermissions'] != 'rwm' else None] if p is not None]) for t in host_config.get('Devices')] if host_config.get('Devices') else None,
```

It looks a bit convoluted, but I wanted to keep it in the same inline one liner theme of the surrounding code. Here's the explanation:
Starting at the "for p in []", the list p iterates over is a specified list of strings, in the correct order, that corresponds to the keys that are in the inspect hash. However, the string is replaced with None if it shouldn't be displayed within the configuration string.  E.g. if it's default permissions (rwm), or if the host and container have the same path, then the configuration string would not show these values, so replace them with None.
Then a list of the values from the device config hash, corresponding to the listed key is generated, but a None key is ignored with "if p is not None".  So at this stage, we would have something like ["/dev/on/host","/dev/in/container","rw" ], or ["/dev/on/host",None,"rw"] and then that list is joined together with a ":" to form the correct string.  This is repeated for every device hash, setting t to that hash and the result is compiled into a list of configuration strings, just like what would be set in the .yml file.
Finally, it just returns None if there is no device config.



Discussion:
<!-- @gbannister: Thanks for filing this bug report! -->

@ansible ping, this issue is waiting for your response.
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)

<!--- boilerplate: issue_bug_new --->


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
@@ -1181,6 +1181,7 @@ def has_different_configuration(self, image):
         self.parameters.expected_etc_hosts = self._convert_simple_dict_to_list('etc_hosts')
         self.parameters.expected_env = self._get_expected_env(image)
         self.parameters.expected_cmd = self._get_expected_cmd()
+        self.parameters.expected_devices = self._get_expected_devices()
 
         if not self.container.get('HostConfig'):
             self.fail("has_config_diff: Error parsing container properties. HostConfig missing.")
@@ -1208,7 +1209,7 @@ def has_different_configuration(self, image):
             detach=detach,
             interactive=config.get('OpenStdin'),
             capabilities=host_config.get('CapAdd'),
-            devices=host_config.get('Devices'),
+            expected_devices=host_config.get('Devices'),
             dns_servers=host_config.get('Dns'),
             dns_opts=host_config.get('DnsOptions'),
             dns_search_domains=host_config.get('DnsSearch'),
@@ -1433,6 +1434,37 @@ def has_extra_networks(self):
                     extra_networks.append(dict(name=network, id=network_config['NetworkID']))
         return extra, extra_networks
 
+    def _get_expected_devices(self):
+        if not self.parameters.devices:
+            return None
+        expected_devices = []
+        for device in self.parameters.devices:
+            parts = device.split(':')
+            if len(parts) == 1:
+                expected_devices.append(
+                    dict(
+                        CgroupPermissions='rwm',
+                        PathInContainer=parts[0],
+                        PathOnHost=parts[0]
+                    ))
+            elif len(parts) == 2:
+                parts = device.split(':')
+                expected_devices.append(
+                    dict(
+                        CgroupPermissions='rwm',
+                        PathInContainer=parts[1],
+                        PathOnHost=parts[0]
+                    )
+                )
+            else:
+                expected_devices.append(
+                    dict(
+                    CgroupPermissions=parts[2],
+                    PathInContainer=parts[1],
+                    PathOnHost=parts[0]
+                ))
+        return expected_devices
+
     def _get_expected_entrypoint(self):
         self.log('_get_expected_entrypoint')
         if not self.parameters.entrypoint:


Test patch: