Browse Source

Add docker-compose event

Signed-off-by: Daniel Nephin <[email protected]>
Daniel Nephin 10 years ago
parent
commit
d1d3969661

+ 23 - 0
compose/cli/main.py

@@ -2,6 +2,7 @@ from __future__ import absolute_import
 from __future__ import print_function
 from __future__ import unicode_literals
 
+import json
 import logging
 import re
 import signal
@@ -132,6 +133,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
@@ -244,6 +246,27 @@ class TopLevelCommand(DocoptCommand):
             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.

+ 1 - 0
compose/const.py

@@ -6,6 +6,7 @@ import sys
 
 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'

+ 37 - 1
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 . import parallel
 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 ConvergenceStrategy
 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 @@ class Project(object):
         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,

+ 4 - 0
compose/utils.py

@@ -88,3 +88,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)

+ 34 - 0
docs/reference/events.md

@@ -0,0 +1,34 @@
+<!--[metadata]>
++++
+title = "events"
+description = "Receive real time events from containers."
+keywords = ["fig, composition, compose, docker, orchestration, cli, events"]
+[menu.main]
+identifier="events.compose"
+parent = "smn_compose_cli"
++++
+<![end-metadata]-->
+
+# 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",
+}
+```

+ 6 - 5
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
 

+ 11 - 0
tests/acceptance/cli_test.py

@@ -1,6 +1,7 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+import json
 import os
 import shlex
 import signal
@@ -855,6 +856,16 @@ class CLITestCase(DockerClientTestCase):
         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)

+ 79 - 0
tests/unit/project_test.py

@@ -1,6 +1,8 @@
 from __future__ import absolute_import
 from __future__ import unicode_literals
 
+import datetime
+
 import docker
 
 from .. import mock
@@ -197,6 +199,83 @@ class ProjectTest(unittest.TestCase):
                 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, [
             {