Browse Source

Implement 'healthcheck' option

Signed-off-by: Aanand Prasad <[email protected]>
Aanand Prasad 9 years ago
parent
commit
716a6baa59

+ 29 - 0
compose/config/config.py

@@ -17,6 +17,7 @@ from ..const import COMPOSEFILE_V2_0 as V2_0
 from ..const import COMPOSEFILE_V2_1 as V2_1
 from ..const import COMPOSEFILE_V2_1 as V2_1
 from ..const import COMPOSEFILE_V3_0 as V3_0
 from ..const import COMPOSEFILE_V3_0 as V3_0
 from ..utils import build_string_dict
 from ..utils import build_string_dict
+from ..utils import parse_nanoseconds_int
 from ..utils import splitdrive
 from ..utils import splitdrive
 from .environment import env_vars_from_file
 from .environment import env_vars_from_file
 from .environment import Environment
 from .environment import Environment
@@ -65,6 +66,7 @@ DOCKER_CONFIG_KEYS = [
     'extra_hosts',
     'extra_hosts',
     'group_add',
     'group_add',
     'hostname',
     'hostname',
+    'healthcheck',
     'image',
     'image',
     'ipc',
     'ipc',
     'labels',
     'labels',
@@ -642,6 +644,10 @@ def process_service(service_config):
     if 'extra_hosts' in service_dict:
     if 'extra_hosts' in service_dict:
         service_dict['extra_hosts'] = parse_extra_hosts(service_dict['extra_hosts'])
         service_dict['extra_hosts'] = parse_extra_hosts(service_dict['extra_hosts'])
 
 
+    if 'healthcheck' in service_dict:
+        service_dict['healthcheck'] = process_healthcheck(
+            service_dict['healthcheck'], service_config.name)
+
     for field in ['dns', 'dns_search', 'tmpfs']:
     for field in ['dns', 'dns_search', 'tmpfs']:
         if field in service_dict:
         if field in service_dict:
             service_dict[field] = to_list(service_dict[field])
             service_dict[field] = to_list(service_dict[field])
@@ -649,6 +655,29 @@ def process_service(service_config):
     return service_dict
     return service_dict
 
 
 
 
+def process_healthcheck(raw, service_name):
+    hc = {}
+
+    if raw.get('disable'):
+        if len(raw) > 1:
+            raise ConfigurationError(
+                'Service "{}" defines an invalid healthcheck: '
+                '"disable: true" cannot be combined with other options'
+                .format(service_name))
+        hc['test'] = ['NONE']
+    elif 'test' in raw:
+        hc['test'] = raw['test']
+
+    if 'interval' in raw:
+        hc['interval'] = parse_nanoseconds_int(raw['interval'])
+    if 'timeout' in raw:
+        hc['timeout'] = parse_nanoseconds_int(raw['timeout'])
+    if 'retries' in raw:
+        hc['retries'] = raw['retries']
+
+    return hc
+
+
 def finalize_service(service_config, service_names, version, environment):
 def finalize_service(service_config, service_names, version, environment):
     service_dict = dict(service_config.config)
     service_dict = dict(service_config.config)
 
 

+ 3 - 2
compose/config/config_schema_v3.0.json

@@ -205,12 +205,13 @@
         "interval": {"type":"string"},
         "interval": {"type":"string"},
         "timeout": {"type":"string"},
         "timeout": {"type":"string"},
         "retries": {"type": "number"},
         "retries": {"type": "number"},
-        "command": {
+        "test": {
           "oneOf": [
           "oneOf": [
             {"type": "string"},
             {"type": "string"},
             {"type": "array", "items": {"type": "string"}}
             {"type": "array", "items": {"type": "string"}}
           ]
           ]
-        }
+        },
+        "disable": {"type": "boolean"}
       },
       },
       "additionalProperties": false
       "additionalProperties": false
     },
     },

+ 2 - 2
compose/service.py

@@ -17,7 +17,6 @@ from docker.utils.ports import split_port
 
 
 from . import __version__
 from . import __version__
 from . import progress_stream
 from . import progress_stream
-from . import timeparse
 from .config import DOCKER_CONFIG_KEYS
 from .config import DOCKER_CONFIG_KEYS
 from .config import merge_environment
 from .config import merge_environment
 from .config.types import VolumeSpec
 from .config.types import VolumeSpec
@@ -35,6 +34,7 @@ from .parallel import parallel_start
 from .progress_stream import stream_output
 from .progress_stream import stream_output
 from .progress_stream import StreamOutputError
 from .progress_stream import StreamOutputError
 from .utils import json_hash
 from .utils import json_hash
+from .utils import parse_seconds_float
 
 
 
 
 log = logging.getLogger(__name__)
 log = logging.getLogger(__name__)
@@ -450,7 +450,7 @@ class Service(object):
     def stop_timeout(self, timeout):
     def stop_timeout(self, timeout):
         if timeout is not None:
         if timeout is not None:
             return timeout
             return timeout
-        timeout = timeparse.timeparse(self.options.get('stop_grace_period') or '')
+        timeout = parse_seconds_float(self.options.get('stop_grace_period'))
         if timeout is not None:
         if timeout is not None:
             return timeout
             return timeout
         return DEFAULT_TIMEOUT
         return DEFAULT_TIMEOUT

+ 16 - 0
compose/utils.py

@@ -11,6 +11,7 @@ import ntpath
 import six
 import six
 
 
 from .errors import StreamParseError
 from .errors import StreamParseError
+from .timeparse import timeparse
 
 
 
 
 json_decoder = json.JSONDecoder()
 json_decoder = json.JSONDecoder()
@@ -107,6 +108,21 @@ def microseconds_from_time_nano(time_nano):
     return int(time_nano % 1000000000 / 1000)
     return int(time_nano % 1000000000 / 1000)
 
 
 
 
+def nanoseconds_from_time_seconds(time_seconds):
+    return time_seconds * 1000000000
+
+
+def parse_seconds_float(value):
+    return timeparse(value or '')
+
+
+def parse_nanoseconds_int(value):
+    parsed = timeparse(value or '')
+    if parsed is None:
+        return None
+    return int(parsed * 1000000000)
+
+
 def build_string_dict(source_dict):
 def build_string_dict(source_dict):
     return dict((k, str(v if v is not None else '')) for k, v in source_dict.items())
     return dict((k, str(v if v is not None else '')) for k, v in source_dict.items())
 
 

+ 1 - 1
requirements.txt

@@ -1,11 +1,11 @@
 PyYAML==3.11
 PyYAML==3.11
 backports.ssl-match-hostname==3.5.0.1; python_version < '3'
 backports.ssl-match-hostname==3.5.0.1; python_version < '3'
 cached-property==1.2.0
 cached-property==1.2.0
-docker-py==1.10.6
 dockerpty==0.4.1
 dockerpty==0.4.1
 docopt==0.6.1
 docopt==0.6.1
 enum34==1.0.4; python_version < '3.4'
 enum34==1.0.4; python_version < '3.4'
 functools32==3.2.3.post2; python_version < '3.2'
 functools32==3.2.3.post2; python_version < '3.2'
+git+https://github.com/docker/docker-py.git@2ff7371ae7703033f981e1b137a3be0caf7a4f9c#egg=docker-py
 ipaddress==1.0.16
 ipaddress==1.0.16
 jsonschema==2.5.1
 jsonschema==2.5.1
 pypiwin32==219; sys_platform == 'win32'
 pypiwin32==219; sys_platform == 'win32'

+ 47 - 3
tests/acceptance/cli_test.py

@@ -21,6 +21,7 @@ from .. import mock
 from compose.cli.command import get_project
 from compose.cli.command import get_project
 from compose.container import Container
 from compose.container import Container
 from compose.project import OneOffFilter
 from compose.project import OneOffFilter
+from compose.utils import nanoseconds_from_time_seconds
 from tests.integration.testcases import DockerClientTestCase
 from tests.integration.testcases import DockerClientTestCase
 from tests.integration.testcases import get_links
 from tests.integration.testcases import get_links
 from tests.integration.testcases import pull_busybox
 from tests.integration.testcases import pull_busybox
@@ -329,9 +330,9 @@ class CLITestCase(DockerClientTestCase):
                     },
                     },
 
 
                     'healthcheck': {
                     'healthcheck': {
-                        'command': 'cat /etc/passwd',
-                        'interval': '10s',
-                        'timeout': '1s',
+                        'test': 'cat /etc/passwd',
+                        'interval': 10000000000,
+                        'timeout': 1000000000,
                         'retries': 5,
                         'retries': 5,
                     },
                     },
 
 
@@ -925,6 +926,49 @@ class CLITestCase(DockerClientTestCase):
         assert foo_container.get('HostConfig.NetworkMode') == \
         assert foo_container.get('HostConfig.NetworkMode') == \
             'container:{}'.format(bar_container.id)
             'container:{}'.format(bar_container.id)
 
 
+    def test_up_with_healthcheck(self):
+        def wait_on_health_status(container, status):
+            def condition():
+                container.inspect()
+                return container.get('State.Health.Status') == status
+
+            return wait_on_condition(condition, delay=0.5)
+
+        self.base_dir = 'tests/fixtures/healthcheck'
+        self.dispatch(['up', '-d'], None)
+
+        passes = self.project.get_service('passes')
+        passes_container = passes.containers()[0]
+
+        assert passes_container.get('Config.Healthcheck') == {
+            "Test": ["CMD-SHELL", "/bin/true"],
+            "Interval": nanoseconds_from_time_seconds(1),
+            "Timeout": nanoseconds_from_time_seconds(30*60),
+            "Retries": 1,
+        }
+
+        wait_on_health_status(passes_container, 'healthy')
+
+        fails = self.project.get_service('fails')
+        fails_container = fails.containers()[0]
+
+        assert fails_container.get('Config.Healthcheck') == {
+            "Test": ["CMD", "/bin/false"],
+            "Interval": nanoseconds_from_time_seconds(2.5),
+            "Retries": 2,
+        }
+
+        wait_on_health_status(fails_container, 'unhealthy')
+
+        disabled = self.project.get_service('disabled')
+        disabled_container = disabled.containers()[0]
+
+        assert disabled_container.get('Config.Healthcheck') == {
+            "Test": ["NONE"],
+        }
+
+        assert 'Health' not in disabled_container.get('State')
+
     def test_up_with_no_deps(self):
     def test_up_with_no_deps(self):
         self.base_dir = 'tests/fixtures/links-composefile'
         self.base_dir = 'tests/fixtures/links-composefile'
         self.dispatch(['up', '-d', '--no-deps', 'web'], None)
         self.dispatch(['up', '-d', '--no-deps', 'web'], None)

+ 24 - 0
tests/fixtures/healthcheck/docker-compose.yml

@@ -0,0 +1,24 @@
+version: "3"
+services:
+  passes:
+    image: busybox
+    command: top
+    healthcheck:
+      test: "/bin/true"
+      interval: 1s
+      timeout: 30m
+      retries: 1
+
+  fails:
+    image: busybox
+    command: top
+    healthcheck:
+      test: ["CMD", "/bin/false"]
+      interval: 2.5s
+      retries: 2
+
+  disabled:
+    image: busybox
+    command: top
+    healthcheck:
+      disable: true

+ 1 - 1
tests/fixtures/v3-full/docker-compose.yml

@@ -29,7 +29,7 @@ services:
         constraints: [node=foo]
         constraints: [node=foo]
 
 
     healthcheck:
     healthcheck:
-      command: cat /etc/passwd
+      test: cat /etc/passwd
       interval: 10s
       interval: 10s
       timeout: 1s
       timeout: 1s
       retries: 5
       retries: 5

+ 49 - 0
tests/unit/config/config_test.py

@@ -24,6 +24,7 @@ from compose.config.errors import ConfigurationError
 from compose.config.errors import VERSION_EXPLANATION
 from compose.config.errors import VERSION_EXPLANATION
 from compose.config.types import VolumeSpec
 from compose.config.types import VolumeSpec
 from compose.const import IS_WINDOWS_PLATFORM
 from compose.const import IS_WINDOWS_PLATFORM
+from compose.utils import nanoseconds_from_time_seconds
 from tests import mock
 from tests import mock
 from tests import unittest
 from tests import unittest
 
 
@@ -3171,6 +3172,54 @@ class BuildPathTest(unittest.TestCase):
             assert 'build path' in exc.exconly()
             assert 'build path' in exc.exconly()
 
 
 
 
+class HealthcheckTest(unittest.TestCase):
+    def test_healthcheck(self):
+        service_dict = make_service_dict(
+            'test',
+            {'healthcheck': {
+                'test': ['CMD', 'true'],
+                'interval': '1s',
+                'timeout': '1m',
+                'retries': 3,
+            }},
+            '.',
+        )
+
+        assert service_dict['healthcheck'] == {
+            'test': ['CMD', 'true'],
+            'interval': nanoseconds_from_time_seconds(1),
+            'timeout': nanoseconds_from_time_seconds(60),
+            'retries': 3,
+        }
+
+    def test_disable(self):
+        service_dict = make_service_dict(
+            'test',
+            {'healthcheck': {
+                'disable': True,
+            }},
+            '.',
+        )
+
+        assert service_dict['healthcheck'] == {
+            'test': ['NONE'],
+        }
+
+    def test_disable_with_other_config_is_invalid(self):
+        with pytest.raises(ConfigurationError) as excinfo:
+            make_service_dict(
+                'invalid-healthcheck',
+                {'healthcheck': {
+                    'disable': True,
+                    'interval': '1s',
+                }},
+                '.',
+            )
+
+        assert 'invalid-healthcheck' in excinfo.exconly()
+        assert 'disable' in excinfo.exconly()
+
+
 class GetDefaultConfigFilesTestCase(unittest.TestCase):
 class GetDefaultConfigFilesTestCase(unittest.TestCase):
 
 
     files = [
     files = [