Repository: simphony/simphony-remote

Issue:
Image.from_docker_dict and Container.from_docker_dict take inconsistent inputs
`Container.from_docker_dict` assumes inputs from the result of `docker.client.containers` but not `docker.client.inspect_container`, whilst `Image.from_docker_dict` can take inputs from both.

It would be useful and neater if `Container.from_docker_dict` accepts input from the result `docker.client.inspect_container` as well.

Follows #79 


Discussion:

Patch:
diff --git a/remoteappmanager/docker/container.py b/remoteappmanager/docker/container.py
index cb484b0..f92304c 100644
--- a/remoteappmanager/docker/container.py
+++ b/remoteappmanager/docker/container.py
@@ -56,7 +56,7 @@ class Container(HasTraits):
             ")>")
 
     @classmethod
-    def from_docker_containers_dict(cls, docker_dict):
+    def from_docker_dict(cls, docker_dict):
         """Returns a Container object with the info given by a
         docker Client.
 
@@ -74,24 +74,66 @@ class Container(HasTraits):
         >>> # containers is a list of dict
         >>> containers = docker.Client().containers()
 
-        >>> Container.from_docker_containers_dict(containers[0])
+        >>> Container.from_docker_dict(containers[0])
         """
-        ip = cls.ip.default_value
-        port = cls.port.default_value
-
-        if docker_dict.get('Ports'):
-            ip = docker_dict['Ports'][0].get('IP', ip)
-            port = docker_dict['Ports'][0].get('PublicPort', port)
-
-        labels = docker_dict.get("Labels", {})
-        mapping_id = labels.get(SIMPHONY_NS+"mapping_id", "")
-        url_id = labels.get(SIMPHONY_NS+"url_id", "")
-
-        return cls(docker_id=docker_dict.get('Id', ''),
-                   name=docker_dict.get('Names', ('',))[0],
-                   image_name=docker_dict.get('Image', ''),
-                   image_id=docker_dict.get('ImageID', ''),
-                   mapping_id=mapping_id,
-                   ip=ip,
-                   port=port,
-                   url_id=url_id)
+
+        is_inspect_container_output = ("Config" in docker_dict)
+
+        kwargs = dict(
+            docker_id=docker_dict.get('Id') or '',
+            ip=cls.ip.default_value,
+            port=cls.port.default_value,
+        )
+
+        if is_inspect_container_output:
+            # It's a client.inspect_container() output
+
+            network_settings = docker_dict.get("NetworkSettings") or {}
+            ports = network_settings.get("Ports") or {}
+            # unfortunately, in the case of a running container, we don't have
+            # a single list. Instead, we have a dict where the keys are
+            # the "port identifier" (e.g. 8888/tcp) and the value is a list
+            # of dictionaries.
+            # We assume that we only have one, as above
+            if len(ports) > 1:
+                raise ValueError("Container Ports had more than one element.")
+
+            if len(ports):
+                port_values = list(ports.values())[0]
+                if len(port_values) > 1:
+                    raise ValueError("Container Ports values had "
+                                     "more than one element.")
+
+                if len(port_values):
+                    kwargs["ip"] = port_values[0].get("HostIp") or kwargs["ip"]
+                    kwargs["port"] = int(port_values[0].get("HostPort") or
+                                         kwargs["port"])
+
+            config = docker_dict.get("Config", {})
+            labels = config.get("Labels")
+
+            kwargs["image_name"] = config.get("Image")
+            kwargs["image_id"] = docker_dict["Image"]
+            kwargs["name"] = docker_dict["Name"]
+        else:
+            # It's a client.containers() output, so we have different rules.
+            ports = docker_dict.get('Ports') or []
+            if len(ports) > 1:
+                raise ValueError("Container Ports had more than one element.")
+
+            if len(ports):
+                kwargs["ip"] = ports[0].get('IP') or kwargs["ip"]
+                kwargs["port"] = int(ports[0].get('PublicPort') or
+                                     kwargs["port"])
+
+            labels = docker_dict.get("Labels") or {}
+
+            kwargs["image_name"] = docker_dict.get('Image') or ''
+            kwargs["image_id"] = docker_dict.get("ImageID") or ''
+            names = docker_dict.get("Names") or ('', )
+            kwargs["name"] = names[0]
+
+        kwargs["mapping_id"] = labels.get(SIMPHONY_NS+"mapping_id") or ""
+        kwargs["url_id"] = labels.get(SIMPHONY_NS+"url_id") or ""
+
+        return cls(**kwargs)
diff --git a/remoteappmanager/docker/container_manager.py b/remoteappmanager/docker/container_manager.py
index 1db82e5..7b85d53 100644
--- a/remoteappmanager/docker/container_manager.py
+++ b/remoteappmanager/docker/container_manager.py
@@ -166,8 +166,8 @@ class ContainerManager(LoggingMixin):
         infos = yield self.docker_client.containers(filters=filters)
         for info in infos:
             try:
-                container = Container.from_docker_containers_dict(info)
-            except Exception:
+                container = Container.from_docker_dict(info)
+            except ValueError:
                 self.log.exception("Unable to parse container info.")
                 continue
 
diff --git a/remoteappmanager/restresources/container.py b/remoteappmanager/restresources/container.py
index c3cd5e8..64bf36a 100644
--- a/remoteappmanager/restresources/container.py
+++ b/remoteappmanager/restresources/container.py
@@ -118,7 +118,7 @@ class Container(Resource):
         if not container_dict:
             return None
 
-        return DockerContainer.from_docker_containers_dict(container_dict[0])
+        return DockerContainer.from_docker_dict(container_dict[0])
 
     @gen.coroutine
     def _start_container(self, user_name, app, policy, mapping_id):


Test patch:
diff --git a/tests/docker/test_container.py b/tests/docker/test_container.py
index f5223dc..249eac9 100644
--- a/tests/docker/test_container.py
+++ b/tests/docker/test_container.py
@@ -1,7 +1,8 @@
 from unittest import TestCase
 
 from remoteappmanager.docker.container import Container
-from tests.utils import assert_containers_equal
+from tests.utils import assert_containers_equal, \
+    mock_docker_client_with_running_containers
 
 
 class TestContainer(TestCase):
@@ -42,7 +43,7 @@ class TestContainer(TestCase):
             'Status': 'Up 56 minutes'}
 
         # Container with public port
-        actual = Container.from_docker_containers_dict(container_dict)
+        actual = Container.from_docker_dict(container_dict)
         expected = Container(
             docker_id='248e45e717cd740ae763a1c565',
             name='/remoteexec-user-empty-ubuntu_3Alatest',
@@ -75,7 +76,7 @@ class TestContainer(TestCase):
             'Status': 'Up 56 minutes'}
 
         # Container without public port
-        actual = Container.from_docker_containers_dict(container_dict)
+        actual = Container.from_docker_dict(container_dict)
         expected = Container(
             docker_id='812c765d0549be0ab831ae8348',
             name='/remoteexec-user-empty-ubuntu_3Alatest',
@@ -86,3 +87,57 @@ class TestContainer(TestCase):
             url_id="8e2fe66d5de74db9bbab50c0d2f92b33")
 
         assert_containers_equal(self, actual, expected)
+
+    def test_from_docker_dict_inspect_container(self):
+        client = mock_docker_client_with_running_containers()
+        actual = Container.from_docker_dict(
+            client.inspect_container("id"))
+
+        expected = Container(
+            docker_id='35d88fe321c3d575ec3be64f54b8967ef49c0dc92395bf4c1e511ed3e6ae0c79',  # noqa
+            name='/remoteexec-username-simphonyproject_2Fsimphonic-mayavi_5F1',
+            image_name='simphonyproject/simphonic-mayavi',
+            image_id='sha256:f43b749341ee37b56e7bd8d99f09629f311aaec35a8045a39185b5659edef169',  # noqa
+            ip='0.0.0.0',
+            port=32782,
+            url_id="55555555555555555555555555555555",
+            mapping_id="1c08c87878634e90af43d799e90f61d2")
+
+        assert_containers_equal(self, actual, expected)
+
+    def test_multiple_ports_data(self):
+        client = mock_docker_client_with_running_containers()
+        docker_dict = client.inspect_container("id")
+        docker_dict["NetworkSettings"]["Ports"] = {
+            '8888/tcp': [{'HostIp': '0.0.0.0', 'HostPort': '32782'}],
+            '8889/tcp': [{'HostIp': '0.0.0.0', 'HostPort': '32783'}]
+        }
+        with self.assertRaises(ValueError):
+            Container.from_docker_dict(docker_dict)
+
+        docker_dict["NetworkSettings"]["Ports"] = {
+            '8888/tcp': [
+                {'HostIp': '0.0.0.0', 'HostPort': '32782'},
+                {'HostIp': '0.0.0.0', 'HostPort': '32783'}
+            ]
+        }
+        with self.assertRaises(ValueError):
+            Container.from_docker_dict(docker_dict)
+
+        docker_dict = client.containers()[0]
+        docker_dict["Ports"] = [
+             {
+                'IP': '0.0.0.0',
+                'PublicIP': 34567,
+                'PrivatePort': 22,
+                'Type': 'tcp'
+             },
+             {
+                'IP': '0.0.0.0',
+                'PublicIP': 34562,
+                'PrivatePort': 21,
+                'Type': 'tcp'
+             }
+        ]
+        with self.assertRaises(ValueError):
+            Container.from_docker_dict(docker_dict)
diff --git a/tests/utils.py b/tests/utils.py
index 9279636..2556bcd 100644
--- a/tests/utils.py
+++ b/tests/utils.py
@@ -110,6 +110,30 @@ def mock_docker_client_with_running_containers():
          'State': 'running',
          'Status': 'Up About an hour'},
     ]
+    client.inspect_container.return_value = {
+        'Config': {
+                   'Image': 'simphonyproject/simphonic-mayavi',
+                   'Labels': {'eu.simphony-project.docker.description': 'Ubuntu '
+                                                                        'machine '
+                                                                        'with '
+                                                                        'simphony '
+                                                                        'framework '
+                                                                        'preinstalled',
+                              'eu.simphony-project.docker.mapping_id': '1c08c87878634e90af43d799e90f61d2',
+                              'eu.simphony-project.docker.ui_name': 'Simphony '
+                                                                    'Framework '
+                                                                    '(w/ mayavi)',
+                              'eu.simphony-project.docker.url_id': '55555555555555555555555555555555',
+                              'eu.simphony-project.docker.user': 'username'},
+                    },
+        'Id': '35d88fe321c3d575ec3be64f54b8967ef49c0dc92395bf4c1e511ed3e6ae0c79',
+        'Image': 'sha256:f43b749341ee37b56e7bd8d99f09629f311aaec35a8045a39185b5659edef169',
+        'Name': '/remoteexec-username-simphonyproject_2Fsimphonic-mayavi_5F1',
+        'NetworkSettings': {
+                            'Ports': {'8888/tcp': [{'HostIp': '0.0.0.0',
+                                                    'HostPort': '32782'}]},
+                           },
+    }
 
     return client