diff --git a/compose/cli/main.py b/compose/cli/main.py index 006d33ecb64..5de6943ee42 100644 --- a/compose/cli/main.py +++ b/compose/cli/main.py @@ -1,6 +1,7 @@ from __future__ import print_function from __future__ import unicode_literals +import json import logging import re import signal @@ -131,6 +132,7 @@ class TopLevelCommand(DocoptCommand): build Build or rebuild services config Validate and view the compose file create Create services + events Receive real time events from containers help Get help on a command kill Kill containers logs View output from containers @@ -243,6 +245,27 @@ def create(self, project, options): do_build=not options['--no-build'] ) + def events(self, project, options): + """ + Receive real time events from containers. + + Usage: events [options] [SERVICE...] + + Options: + --json Output events as a stream of json objects + """ + def format_event(event): + return ("{time}: service={service} event={event} " + "container={container} image={image}").format(**event) + + def json_format_event(event): + event['time'] = event['time'].isoformat() + return json.dumps(event) + + for event in project.events(): + formatter = json_format_event if options['--json'] else format_event + print(formatter(event)) + def help(self, project, options): """ Get help on a command. diff --git a/compose/const.py b/compose/const.py index 9c607ca26d8..e733e01a76d 100644 --- a/compose/const.py +++ b/compose/const.py @@ -3,6 +3,7 @@ DEFAULT_TIMEOUT = 10 HTTP_TIMEOUT = int(os.environ.get('COMPOSE_HTTP_TIMEOUT', os.environ.get('DOCKER_CLIENT_TIMEOUT', 60))) +IMAGE_EVENTS = ['delete', 'import', 'pull', 'push', '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 3801bbb9f87..b4eed7c8d77 100644 --- a/compose/project.py +++ b/compose/project.py @@ -1,6 +1,7 @@ from __future__ import absolute_import from __future__ import unicode_literals +import datetime import logging from functools import reduce @@ -11,6 +12,7 @@ from .config import ConfigurationError from .config.sort_services import get_service_name_from_net from .const import DEFAULT_TIMEOUT +from .const import IMAGE_EVENTS from .const import LABEL_ONE_OFF from .const import LABEL_PROJECT from .const import LABEL_SERVICE @@ -20,6 +22,7 @@ from .service import Net from .service import Service from .service import ServiceNet +from .utils import microseconds_from_time_nano from .volume import Volume @@ -267,7 +270,40 @@ def create(self, service_names=None, strategy=ConvergenceStrategy.changed, do_bu plans = self._get_convergence_plans(services, strategy) for service in services: - service.execute_convergence_plan(plans[service.name], do_build, detached=True, start=False) + service.execute_convergence_plan( + plans[service.name], + do_build, + detached=True, + start=False) + + def events(self): + def build_container_event(event, container): + time = datetime.datetime.fromtimestamp(event['time']) + time = time.replace( + microsecond=microseconds_from_time_nano(event['timeNano'])) + return { + 'service': container.service, + 'event': event['status'], + 'container': container.id, + 'image': event['from'], + 'time': time, + } + + service_names = set(self.service_names) + for event in self.client.events( + filters={'label': self.labels()}, + decode=True + ): + if event['status'] in IMAGE_EVENTS: + # We don't receive any image events because labels aren't applied + # to images + continue + + # TODO: get labels from the API v1.22 , see github issue 2618 + container = Container.from_id(self.client, event['id']) + if container.service not in service_names: + continue + yield build_container_event(event, container) def up(self, service_names=None, diff --git a/compose/utils.py b/compose/utils.py index 362629bc2b0..8102fd23d83 100644 --- a/compose/utils.py +++ b/compose/utils.py @@ -85,3 +85,7 @@ def json_hash(obj): h = hashlib.sha256() h.update(dump.encode('utf8')) return h.hexdigest() + + +def microseconds_from_time_nano(time_nano): + return int(time_nano % 1000000000 / 1000) diff --git a/docs/reference/events.md b/docs/reference/events.md new file mode 100644 index 00000000000..827258f2499 --- /dev/null +++ b/docs/reference/events.md @@ -0,0 +1,34 @@ + + +# events + +``` +Usage: events [options] [SERVICE...] + +Options: + --json Output events as a stream of json objects +``` + +Stream container events for every container in the project. + +With the `--json` flag, a json object will be printed one per line with the +format: + +``` +{ + "service": "web", + "event": "create", + "container": "213cf75fc39a", + "image": "alpine:edge", + "time": "2015-11-20T18:01:03.615550", +} +``` diff --git a/docs/reference/index.md b/docs/reference/index.md index b2fb5bcadcf..1635b60c735 100644 --- a/docs/reference/index.md +++ b/docs/reference/index.md @@ -14,19 +14,20 @@ parent = "smn_compose_ref" The following pages describe the usage information for the [docker-compose](docker-compose.md) subcommands. You can also see this information by running `docker-compose [SUBCOMMAND] --help` from the command line. * [build](build.md) +* [events](events.md) * [help](help.md) * [kill](kill.md) -* [ps](ps.md) -* [restart](restart.md) -* [run](run.md) -* [start](start.md) -* [up](up.md) * [logs](logs.md) * [port](port.md) +* [ps](ps.md) * [pull](pull.md) +* [restart](restart.md) * [rm](rm.md) +* [run](run.md) * [scale](scale.md) +* [start](start.md) * [stop](stop.md) +* [up](up.md) ## Where to go next diff --git a/tests/acceptance/cli_test.py b/tests/acceptance/cli_test.py index 1885727a13b..093eeaee5fc 100644 --- a/tests/acceptance/cli_test.py +++ b/tests/acceptance/cli_test.py @@ -1,5 +1,6 @@ from __future__ import absolute_import +import json import os import shlex import signal @@ -854,6 +855,16 @@ def get_port(number, index=None): self.assertEqual(get_port(3000, index=2), containers[1].get_local_port(3000)) self.assertEqual(get_port(3002), "") + def test_events_json(self): + events_proc = start_process(self.base_dir, ['events', '--json']) + self.dispatch(['up', '-d']) + wait_on_condition(ContainerCountCondition(self.project, 2)) + + os.kill(events_proc.pid, signal.SIGINT) + result = wait_on_process(events_proc, returncode=1) + lines = [json.loads(line) for line in result.stdout.rstrip().split('\n')] + assert [e['event'] for e in lines] == ['create', 'start', 'create', 'start'] + def test_env_file_relative_to_compose_file(self): config_path = os.path.abspath('tests/fixtures/env-file/docker-compose.yml') self.dispatch(['-f', config_path, 'up', '-d'], None) diff --git a/tests/unit/project_test.py b/tests/unit/project_test.py index 4bf5f463659..f290cb800d7 100644 --- a/tests/unit/project_test.py +++ b/tests/unit/project_test.py @@ -1,5 +1,7 @@ from __future__ import unicode_literals +import datetime + import docker from .. import mock @@ -196,6 +198,83 @@ def test_use_volumes_from_service_container(self): project.get_service('test')._get_volumes_from(), [container_ids[0] + ':rw']) + def test_events(self): + services = [Service(name='web'), Service(name='db')] + project = Project('test', services, self.mock_client) + self.mock_client.events.return_value = iter([ + { + 'status': 'create', + 'from': 'example/image', + 'id': 'abcde', + 'time': 1420092061, + 'timeNano': 14200920610000002000, + }, + { + 'status': 'attach', + 'from': 'example/image', + 'id': 'abcde', + 'time': 1420092061, + 'timeNano': 14200920610000003000, + }, + { + 'status': 'create', + 'from': 'example/other', + 'id': 'bdbdbd', + 'time': 1420092061, + 'timeNano': 14200920610000005000, + }, + { + 'status': 'create', + 'from': 'example/db', + 'id': 'ababa', + 'time': 1420092061, + 'timeNano': 14200920610000004000, + }, + ]) + + def dt_with_microseconds(dt, us): + return datetime.datetime.fromtimestamp(dt).replace(microsecond=us) + + def get_container(cid): + if cid == 'abcde': + labels = {LABEL_SERVICE: 'web'} + elif cid == 'ababa': + labels = {LABEL_SERVICE: 'db'} + else: + labels = {} + return {'Id': cid, 'Config': {'Labels': labels}} + + 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 == [ + { + 'service': 'web', + 'event': 'create', + 'container': 'abcde', + 'image': 'example/image', + 'time': dt_with_microseconds(1420092061, 2), + }, + { + 'service': 'web', + 'event': 'attach', + 'container': 'abcde', + 'image': 'example/image', + 'time': dt_with_microseconds(1420092061, 3), + }, + { + 'service': 'db', + 'event': 'create', + 'container': 'ababa', + 'image': 'example/db', + 'time': dt_with_microseconds(1420092061, 4), + }, + ] + def test_net_unset(self): project = Project.from_config('test', Config(None, [ {