Browse Source

Use improved API fields for project events when possible

Signed-off-by: Joffrey F <[email protected]>
Joffrey F 6 years ago
parent
commit
8b293d486e
4 changed files with 233 additions and 13 deletions
  1. 2 1
      compose/cli/log_printer.py
  2. 0 1
      compose/const.py
  3. 60 10
      compose/project.py
  4. 171 1
      tests/unit/project_test.py

+ 2 - 1
compose/cli/log_printer.py

@@ -236,7 +236,8 @@ def watch_events(thread_map, event_stream, presenters, thread_args):
         thread_map[event['id']] = build_thread(
             event['container'],
             next(presenters),
-            *thread_args)
+            *thread_args
+        )
 
 
 def consume_queue(queue, cascade_stop):

+ 0 - 1
compose/const.py

@@ -7,7 +7,6 @@ from .version import ComposeVersion
 
 DEFAULT_TIMEOUT = 10
 HTTP_TIMEOUT = 60
-IMAGE_EVENTS = ['delete', 'import', 'load', 'pull', 'push', 'save', 'tag', 'untag']
 IS_WINDOWS_PLATFORM = (sys.platform == "win32")
 LABEL_CONTAINER_NUMBER = 'com.docker.compose.container-number'
 LABEL_ONE_OFF = 'com.docker.compose.oneoff'

+ 60 - 10
compose/project.py

@@ -10,13 +10,13 @@ from functools import reduce
 import enum
 import six
 from docker.errors import APIError
+from docker.utils import version_lt
 
 from . import parallel
 from .config import ConfigurationError
 from .config.config import V1
 from .config.sort_services import get_container_name_from_network_mode
 from .config.sort_services import get_service_name_from_network_mode
-from .const import IMAGE_EVENTS
 from .const import LABEL_ONE_OFF
 from .const import LABEL_PROJECT
 from .const import LABEL_SERVICE
@@ -402,11 +402,13 @@ class Project(object):
                 detached=True,
                 start=False)
 
-    def events(self, service_names=None):
+    def _legacy_event_processor(self, service_names):
+        # Only for v1 files or when Compose is forced to use an older API version
         def build_container_event(event, container):
             time = datetime.datetime.fromtimestamp(event['time'])
             time = time.replace(
-                microsecond=microseconds_from_time_nano(event['timeNano']))
+                microsecond=microseconds_from_time_nano(event['timeNano'])
+            )
             return {
                 'time': time,
                 'type': 'container',
@@ -425,17 +427,15 @@ class Project(object):
             filters={'label': self.labels()},
             decode=True
         ):
-            # The first part of this condition is a guard against some events
-            # broadcasted by swarm that don't have a status field.
+            # This is a guard against some events broadcasted by swarm that
+            # don't have a status field.
             # See https://github.com/docker/compose/issues/3316
-            if 'status' not in event or event['status'] in IMAGE_EVENTS:
-                # We don't receive any image events because labels aren't applied
-                # to images
+            if 'status' not in event:
                 continue
 
-            # TODO: get labels from the API v1.22 , see github issue 2618
             try:
-                # this can fail if the container has been removed
+                # this can fail if the container has been removed or if the event
+                # refers to an image
                 container = Container.from_id(self.client, event['id'])
             except APIError:
                 continue
@@ -443,6 +443,56 @@ class Project(object):
                 continue
             yield build_container_event(event, container)
 
+    def events(self, service_names=None):
+        if version_lt(self.client.api_version, '1.22'):
+            # New, better event API was introduced in 1.22.
+            return self._legacy_event_processor(service_names)
+
+        def build_container_event(event):
+            container_attrs = event['Actor']['Attributes']
+            time = datetime.datetime.fromtimestamp(event['time'])
+            time = time.replace(
+                microsecond=microseconds_from_time_nano(event['timeNano'])
+            )
+
+            container = None
+            try:
+                container = Container.from_id(self.client, event['id'])
+            except APIError:
+                # Container may have been removed (e.g. if this is a destroy event)
+                pass
+
+            return {
+                'time': time,
+                'type': 'container',
+                'action': event['status'],
+                'id': event['Actor']['ID'],
+                'service': container_attrs.get(LABEL_SERVICE),
+                'attributes': dict([
+                    (k, v) for k, v in container_attrs.items()
+                    if not k.startswith('com.docker.compose.')
+                ]),
+                'container': container,
+            }
+
+        def yield_loop(service_names):
+            for event in self.client.events(
+                filters={'label': self.labels()},
+                decode=True
+            ):
+                # TODO: support other event types
+                if event.get('Type') != 'container':
+                    continue
+
+                try:
+                    if event['Actor']['Attributes'][LABEL_SERVICE] not in service_names:
+                        continue
+                except KeyError:
+                    continue
+                yield build_container_event(event)
+
+        return yield_loop(set(service_names) if service_names else self.service_names)
+
     def up(self,
            service_names=None,
            start_deps=True,

+ 171 - 1
tests/unit/project_test.py

@@ -254,9 +254,10 @@ class ProjectTest(unittest.TestCase):
                 [container_ids[0] + ':rw']
             )
 
-    def test_events(self):
+    def test_events_legacy(self):
         services = [Service(name='web'), Service(name='db')]
         project = Project('test', services, self.mock_client)
+        self.mock_client.api_version = '1.21'
         self.mock_client.events.return_value = iter([
             {
                 'status': 'create',
@@ -362,6 +363,175 @@ class ProjectTest(unittest.TestCase):
             },
         ]
 
+    def test_events(self):
+        services = [Service(name='web'), Service(name='db')]
+        project = Project('test', services, self.mock_client)
+        self.mock_client.api_version = '1.35'
+        self.mock_client.events.return_value = iter([
+            {
+                'status': 'create',
+                'from': 'example/image',
+                'Type': 'container',
+                'Actor': {
+                    'ID': 'abcde',
+                    'Attributes': {
+                        'com.docker.compose.project': 'test',
+                        'com.docker.compose.service': 'web',
+                        'image': 'example/image',
+                        'name': 'test_web_1',
+                    }
+                },
+                'id': 'abcde',
+                'time': 1420092061,
+                'timeNano': 14200920610000002000,
+            },
+            {
+                'status': 'attach',
+                'from': 'example/image',
+                'Type': 'container',
+                'Actor': {
+                    'ID': 'abcde',
+                    'Attributes': {
+                        'com.docker.compose.project': 'test',
+                        'com.docker.compose.service': 'web',
+                        'image': 'example/image',
+                        'name': 'test_web_1',
+                    }
+                },
+                'id': 'abcde',
+                'time': 1420092061,
+                'timeNano': 14200920610000003000,
+            },
+            {
+                'status': 'create',
+                'from': 'example/other',
+                'Type': 'container',
+                'Actor': {
+                    'ID': 'bdbdbd',
+                    'Attributes': {
+                        'image': 'example/other',
+                        'name': 'shrewd_einstein',
+                    }
+                },
+                'id': 'bdbdbd',
+                'time': 1420092061,
+                'timeNano': 14200920610000005000,
+            },
+            {
+                'status': 'create',
+                'from': 'example/db',
+                'Type': 'container',
+                'Actor': {
+                    'ID': 'ababa',
+                    'Attributes': {
+                        'com.docker.compose.project': 'test',
+                        'com.docker.compose.service': 'db',
+                        'image': 'example/db',
+                        'name': 'test_db_1',
+                    }
+                },
+                'id': 'ababa',
+                'time': 1420092061,
+                'timeNano': 14200920610000004000,
+            },
+            {
+                'status': 'destroy',
+                'from': 'example/db',
+                'Type': 'container',
+                'Actor': {
+                    'ID': 'eeeee',
+                    'Attributes': {
+                        'com.docker.compose.project': 'test',
+                        'com.docker.compose.service': 'db',
+                        'image': 'example/db',
+                        'name': 'test_db_1',
+                    }
+                },
+                'id': 'eeeee',
+                'time': 1420092061,
+                'timeNano': 14200920610000004000,
+            },
+        ])
+
+        def dt_with_microseconds(dt, us):
+            return datetime.datetime.fromtimestamp(dt).replace(microsecond=us)
+
+        def get_container(cid):
+            if cid == 'eeeee':
+                raise NotFound(None, None, "oops")
+            if cid == 'abcde':
+                name = 'web'
+                labels = {LABEL_SERVICE: name}
+            elif cid == 'ababa':
+                name = 'db'
+                labels = {LABEL_SERVICE: name}
+            else:
+                labels = {}
+                name = ''
+            return {
+                'Id': cid,
+                'Config': {'Labels': labels},
+                'Name': '/project_%s_1' % name,
+            }
+
+        self.mock_client.inspect_container.side_effect = get_container
+
+        events = project.events()
+
+        events_list = list(events)
+        # Assert the return value is a generator
+        assert not list(events)
+        assert events_list == [
+            {
+                'type': 'container',
+                'service': 'web',
+                'action': 'create',
+                'id': 'abcde',
+                'attributes': {
+                    'name': 'test_web_1',
+                    'image': 'example/image',
+                },
+                'time': dt_with_microseconds(1420092061, 2),
+                'container': Container(None, get_container('abcde')),
+            },
+            {
+                'type': 'container',
+                'service': 'web',
+                'action': 'attach',
+                'id': 'abcde',
+                'attributes': {
+                    'name': 'test_web_1',
+                    'image': 'example/image',
+                },
+                'time': dt_with_microseconds(1420092061, 3),
+                'container': Container(None, get_container('abcde')),
+            },
+            {
+                'type': 'container',
+                'service': 'db',
+                'action': 'create',
+                'id': 'ababa',
+                'attributes': {
+                    'name': 'test_db_1',
+                    'image': 'example/db',
+                },
+                'time': dt_with_microseconds(1420092061, 4),
+                'container': Container(None, get_container('ababa')),
+            },
+            {
+                'type': 'container',
+                'service': 'db',
+                'action': 'destroy',
+                'id': 'eeeee',
+                'attributes': {
+                    'name': 'test_db_1',
+                    'image': 'example/db',
+                },
+                'time': dt_with_microseconds(1420092061, 4),
+                'container': None,
+            },
+        ]
+
     def test_net_unset(self):
         project = Project.from_config(
             name='test',