mirror of https://github.com/docker/compose.git
Merge pull request #4163 from aanand/add-healthcheck
Implement 'healthcheck' option
This commit is contained in:
commit
635a281777
|
@ -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)
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
},
|
},
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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,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'
|
||||||
|
|
|
@ -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
|
||||||
|
@ -331,9 +332,9 @@ class CLITestCase(DockerClientTestCase):
|
||||||
},
|
},
|
||||||
|
|
||||||
'healthcheck': {
|
'healthcheck': {
|
||||||
'command': 'cat /etc/passwd',
|
'test': 'cat /etc/passwd',
|
||||||
'interval': '10s',
|
'interval': 10000000000,
|
||||||
'timeout': '1s',
|
'timeout': 1000000000,
|
||||||
'retries': 5,
|
'retries': 5,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -927,6 +928,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)
|
||||||
|
|
|
@ -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
|
|
@ -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
|
||||||
|
|
|
@ -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 = [
|
||||||
|
|
Loading…
Reference in New Issue