diff --git a/compose/cli/log_printer.py b/compose/cli/log_printer.py index bd6723ef2..8aa93a844 100644 --- a/compose/cli/log_printer.py +++ b/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): diff --git a/compose/const.py b/compose/const.py index 0e66a297a..46d81ae71 100644 --- a/compose/const.py +++ b/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' diff --git a/compose/project.py b/compose/project.py index 8fab09e54..5c4ce6e17 100644 --- a/compose/project.py +++ b/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, diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index f17bc571e..4aea91a0d 100644 --- a/tests/unit/project_test.py +++ b/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',