diff --git a/changelogs/fragments/52419-docker_swarm_service-add-healthcheck-option.yml b/changelogs/fragments/52419-docker_swarm_service-add-healthcheck-option.yml new file mode 100644 index 00000000000..fb4e8c5b0fc --- /dev/null +++ b/changelogs/fragments/52419-docker_swarm_service-add-healthcheck-option.yml @@ -0,0 +1,2 @@ +minor_changes: + - "docker_swarm_service - Added support for ``healthcheck`` parameter." diff --git a/lib/ansible/module_utils/docker/common.py b/lib/ansible/module_utils/docker/common.py index 4a9d1918681..6daafeb743d 100644 --- a/lib/ansible/module_utils/docker/common.py +++ b/lib/ansible/module_utils/docker/common.py @@ -18,8 +18,10 @@ import os import re +from datetime import timedelta from distutils.version import LooseVersion + from ansible.module_utils.basic import AnsibleModule, env_fallback from ansible.module_utils.six.moves.urllib.parse import urlparse from ansible.module_utils.parsing.convert_bool import BOOLEANS_TRUE, BOOLEANS_FALSE @@ -833,3 +835,91 @@ def clean_dict_booleans_for_docker_api(data): v = str(v) result[str(k)] = v return result + + +def convert_duration_to_nanosecond(time_str): + """ + Return time duration in nanosecond. + """ + if not isinstance(time_str, str): + raise ValueError('Missing unit in duration - %s' % time_str) + + regex = re.compile( + r'^(((?P\d+)h)?' + r'((?P\d+)m(?!s))?' + r'((?P\d+)s)?' + r'((?P\d+)ms)?' + r'((?P\d+)us)?)$' + ) + parts = regex.match(time_str) + + if not parts: + raise ValueError('Invalid time duration - %s' % time_str) + + parts = parts.groupdict() + time_params = {} + for (name, value) in parts.items(): + if value: + time_params[name] = int(value) + + delta = timedelta(**time_params) + time_in_nanoseconds = ( + delta.microseconds + (delta.seconds + delta.days * 24 * 3600) * 10 ** 6 + ) * 10 ** 3 + + return time_in_nanoseconds + + +def parse_healthcheck(healthcheck): + """ + Return dictionary of healthcheck parameters and boolean if + healthcheck defined in image was requested to be disabled. + """ + if (not healthcheck) or (not healthcheck.get('test')): + return None, None + + result = dict() + + # All supported healthcheck parameters + options = dict( + test='test', + interval='interval', + timeout='timeout', + start_period='start_period', + retries='retries' + ) + + duration_options = ['interval', 'timeout', 'start_period'] + + for (key, value) in options.items(): + if value in healthcheck: + if healthcheck.get(value) is None: + # due to recursive argument_spec, all keys are always present + # (but have default value None if not specified) + continue + if value in duration_options: + time = convert_duration_to_nanosecond(healthcheck.get(value)) + if time: + result[key] = time + elif healthcheck.get(value): + result[key] = healthcheck.get(value) + if key == 'test': + if isinstance(result[key], (tuple, list)): + result[key] = [str(e) for e in result[key]] + else: + result[key] = ['CMD-SHELL', str(result[key])] + elif key == 'retries': + try: + result[key] = int(result[key]) + except ValueError: + raise ValueError( + 'Cannot parse number of retries for healthcheck. ' + 'Expected an integer, got "{0}".'.format(result[key]) + ) + + if result['test'] == ['NONE']: + # If the user explicitly disables the healthcheck, return None + # as the healthcheck object, and set disable_healthcheck to True + return None, True + + return result, False diff --git a/lib/ansible/modules/cloud/docker/docker_container.py b/lib/ansible/modules/cloud/docker/docker_container.py index c715bccfba6..75814920400 100644 --- a/lib/ansible/modules/cloud/docker/docker_container.py +++ b/lib/ansible/modules/cloud/docker/docker_container.py @@ -863,14 +863,17 @@ docker_container: import os import re import shlex -from datetime import timedelta from distutils.version import LooseVersion from ansible.module_utils.basic import human_to_bytes from ansible.module_utils.docker.common import ( AnsibleDockerClient, - DockerBaseClass, sanitize_result, is_image_name_id, - compare_generic, DifferenceTracker, + DifferenceTracker, + DockerBaseClass, + compare_generic, + is_image_name_id, + sanitize_result, + parse_healthcheck ) from ansible.module_utils.six import string_types @@ -1081,7 +1084,11 @@ class TaskParameters(DockerBaseClass): self.ulimits = self._parse_ulimits() self.sysctls = self._parse_sysctls() self.log_config = self._parse_log_config() - self.healthcheck, self.disable_healthcheck = self._parse_healthcheck() + try: + self.healthcheck, self.disable_healthcheck = parse_healthcheck(self.healthcheck) + except ValueError as e: + self.fail(str(e)) + self.exp_links = None self.volume_binds = self._get_volume_binds(self.volumes) self.pid_mode = self._replace_container_names(self.pid_mode) @@ -1498,81 +1505,6 @@ class TaskParameters(DockerBaseClass): except ValueError as exc: self.fail('Error parsing logging options - %s' % (exc)) - def _parse_healthcheck(self): - ''' - Return dictionary of healthcheck parameters - ''' - if (not self.healthcheck) or (not self.healthcheck.get('test')): - return None, None - - result = dict() - - # all the supported healthecheck parameters - options = dict( - test='test', - interval='interval', - timeout='timeout', - start_period='start_period', - retries='retries' - ) - - duration_options = ['interval', 'timeout', 'start_period'] - - for (key, value) in options.items(): - if value in self.healthcheck: - if self.healthcheck.get(value) is None: - # due to recursive argument_spec, all keys are always present - # (but have default value None if not specified) - continue - if value in duration_options: - time = self._convert_duration_to_nanosecond(self.healthcheck.get(value)) - if time: - result[key] = time - elif self.healthcheck.get(value): - result[key] = self.healthcheck.get(value) - if key == 'test': - if isinstance(result[key], (tuple, list)): - result[key] = [str(e) for e in result[key]] - else: - result[key] = ["CMD-SHELL", str(result[key])] - elif key == 'retries': - try: - result[key] = int(result[key]) - except Exception as dummy: - self.fail('Cannot parse number of retries for healthcheck. ' - 'Expected an integer, got "{0}".'.format(result[key])) - - if result['test'] == ['NONE']: - # If the user explicitly disables the healthcheck, return None - # as the healthcheck object, and set disable_healthcheck to True - return None, True - - return result, False - - def _convert_duration_to_nanosecond(self, time_str): - ''' - Return time duration in nanosecond - ''' - if not isinstance(time_str, str): - self.fail("Missing unit in duration - %s" % time_str) - - regex = re.compile(r'^(((?P\d+)h)?((?P\d+)m(?!s))?((?P\d+)s)?((?P\d+)ms)?((?P\d+)us)?)$') - parts = regex.match(time_str) - - if not parts: - self.fail("Invalid time duration - %s" % time_str) - - parts = parts.groupdict() - time_params = {} - for (name, value) in parts.items(): - if value: - time_params[name] = int(value) - - time = timedelta(**time_params) - time_in_nanoseconds = int(time.total_seconds() * 1000000000) - - return time_in_nanoseconds - def _parse_tmpfs(self): ''' Turn tmpfs into a hash of Tmpfs objects diff --git a/lib/ansible/modules/cloud/docker/docker_swarm_service.py b/lib/ansible/modules/cloud/docker/docker_swarm_service.py index ea7b62f8f9e..5ebb273d658 100644 --- a/lib/ansible/modules/cloud/docker/docker_swarm_service.py +++ b/lib/ansible/modules/cloud/docker/docker_swarm_service.py @@ -67,6 +67,37 @@ options: - Corresponds to the C(--placement-pref) option of C(docker service create). - Requires API version >= 1.27. version_added: 2.8 + healthcheck: + type: dict + description: + - Configure a check that is run to determine whether or not containers for this service are "healthy". + See the docs for the L(HEALTHCHECK Dockerfile instruction,https://docs.docker.com/engine/reference/builder/#healthcheck) + for details on how healthchecks work. + - "I(interval), I(timeout) and I(start_period) are specified as durations. They accept duration as a string in a format + that look like: C(5h34m56s), C(1m30s) etc. The supported units are C(us), C(ms), C(s), C(m) and C(h)." + - Requires API version >= 1.25. + suboptions: + test: + description: + - Command to run to check health. + - Must be either a string or a list. If it is a list, the first item must be one of C(NONE), C(CMD) or C(CMD-SHELL). + interval: + type: str + description: + - Time between running the check. + timeout: + type: str + description: + - Maximum time to allow one check to run. + retries: + type: int + description: + - Consecutive failures needed to report unhealthy. It accept integer value. + start_period: + type: str + description: + - Start period for the container to initialize before starting health-retries countdown. + version_added: "2.8" hostname: type: str description: @@ -567,10 +598,23 @@ EXAMPLES = ''' docker_swarm_service: name: myservice state: absent + +- name: Start service with healthcheck + docker_swarm_service: + name: myservice + image: nginx:1.13 + healthcheck: + # Check if nginx server is healthy by curl'ing the server. + # If this fails or timeouts, the healthcheck fails. + test: ["CMD", "curl", "--fail", "http://nginx.host.com"] + interval: 1m30s + timeout: 10s + retries: 3 + start_period: 30s ''' -import time import shlex +import time import operator from distutils.version import LooseVersion @@ -579,6 +623,9 @@ from ansible.module_utils.docker.common import ( AnsibleDockerClient, DifferenceTracker, DockerBaseClass, + convert_duration_to_nanosecond, + parse_healthcheck + ) from ansible.module_utils.basic import human_to_bytes from ansible.module_utils.six import string_types @@ -649,6 +696,8 @@ class DockerService(DockerBaseClass): self.args = None self.endpoint_mode = None self.dns = None + self.healthcheck = None + self.healthcheck_disabled = None self.hostname = None self.tty = None self.dns_search = None @@ -699,6 +748,8 @@ class DockerService(DockerBaseClass): 'dns': self.dns, 'dns_search': self.dns_search, 'dns_options': self.dns_options, + 'healthcheck': self.healthcheck, + 'healthcheck_disabled': self.healthcheck_disabled, 'hostname': self.hostname, 'env': self.env, 'force_update': self.force_update, @@ -740,6 +791,7 @@ class DockerService(DockerBaseClass): s.dns = ap['dns'] s.dns_search = ap['dns_search'] s.dns_options = ap['dns_options'] + s.healthcheck, s.healthcheck_disabled = parse_healthcheck(ap['healthcheck']) s.hostname = ap['hostname'] s.tty = ap['tty'] s.log_driver = ap['log_driver'] @@ -938,6 +990,8 @@ class DockerService(DockerBaseClass): differences.add('dns_search', parameter=self.dns_search, active=os.dns_search) if self.dns_options is not None and self.dns_options != (os.dns_options or []): differences.add('dns_options', parameter=self.dns_options, active=os.dns_options) + if self.has_healthcheck_changed(os): + differences.add('healthcheck', parameter=self.healthcheck, active=os.healthcheck) if self.hostname is not None and self.hostname != os.hostname: differences.add('hostname', parameter=self.hostname, active=os.hostname) if self.tty is not None and self.tty != os.tty: @@ -946,6 +1000,13 @@ class DockerService(DockerBaseClass): force_update = True return not differences.empty or force_update, differences, needs_rebuild, force_update + def has_healthcheck_changed(self, old_publish): + if self.healthcheck_disabled is False and self.healthcheck is None: + return False + if self.healthcheck_disabled and old_publish.healthcheck is None: + return False + return self.healthcheck != old_publish.healthcheck + def has_publish_changed(self, old_publish): if self.publish is None: return False @@ -1051,6 +1112,8 @@ class DockerService(DockerBaseClass): container_spec_args['user'] = self.user if self.container_labels is not None: container_spec_args['labels'] = self.container_labels + if self.healthcheck is not None: + container_spec_args['healthcheck'] = types.Healthcheck(**self.healthcheck) if self.hostname is not None: container_spec_args['hostname'] = self.hostname if self.stop_signal is not None: @@ -1247,6 +1310,15 @@ class DockerServiceManager(object): ds.args = task_template_data['ContainerSpec'].get('Args') ds.stop_signal = task_template_data['ContainerSpec'].get('StopSignal') + healthcheck_data = task_template_data['ContainerSpec'].get('Healthcheck') + if healthcheck_data: + options = ['test', 'interval', 'timeout', 'start_period', 'retries'] + healthcheck = dict( + (key.lower(), value) for key, value in healthcheck_data.items() + if value is not None and key.lower() in options + ) + ds.healthcheck = healthcheck + update_config_data = raw_data['Spec'].get('UpdateConfig') if update_config_data: ds.update_delay = update_config_data.get('Delay') @@ -1527,6 +1599,10 @@ def _detect_publish_mode_usage(client): return False +def _detect_healthcheck_start_period(client): + return client.module.params['healthcheck']['start_period'] is not None + + def main(): argument_spec = dict( name=dict(required=True), @@ -1578,6 +1654,13 @@ def main(): dns=dict(type='list'), dns_search=dict(type='list'), dns_options=dict(type='list'), + healthcheck=dict(type='dict', options=dict( + test=dict(type='raw'), + interval=dict(type='str'), + timeout=dict(type='str'), + start_period=dict(type='str'), + retries=dict(type='int'), + )), hostname=dict(type='str'), labels=dict(type='dict'), container_labels=dict(type='dict'), @@ -1609,6 +1692,7 @@ def main(): dns_search=dict(docker_py_version='2.6.0', docker_api_version='1.25'), endpoint_mode=dict(docker_py_version='3.0.0', docker_api_version='1.25'), force_update=dict(docker_py_version='2.1.0', docker_api_version='1.25'), + healthcheck=dict(docker_py_version='2.0.0', docker_api_version='1.25'), hostname=dict(docker_py_version='2.2.0', docker_api_version='1.25'), tty=dict(docker_py_version='2.4.0', docker_api_version='1.25'), secrets=dict(docker_py_version='2.1.0', docker_api_version='1.25'), @@ -1625,6 +1709,12 @@ def main(): docker_api_version='1.25', detect_usage=_detect_publish_mode_usage, usage_msg='set publish.mode' + ), + healthcheck_start_period=dict( + docker_py_version='2.4.0', + docker_api_version='1.25', + detect_usage=_detect_healthcheck_start_period, + usage_msg='set healthcheck.start_period' ) ) diff --git a/test/integration/targets/docker_swarm_service/tasks/tests/options.yml b/test/integration/targets/docker_swarm_service/tasks/tests/options.yml index b1a282a5f50..a331b92b544 100644 --- a/test/integration/targets/docker_swarm_service/tasks/tests/options.yml +++ b/test/integration/targets/docker_swarm_service/tasks/tests/options.yml @@ -889,6 +889,133 @@ - "('version is ' ~ docker_api_version ~'. Minimum version required is 1.25') in force_update_1.msg" when: docker_api_version is version('1.25', '<') +#################################################################### +## healthcheck ##################################################### +#################################################################### + +- name: healthcheck + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + command: '/bin/sh -v -c "sleep 10m"' + healthcheck: + test: + - CMD + - sleep + - 1 + timeout: 2s + interval: 0h0m2s3ms4us + retries: 2 + register: healthcheck_1 + +- name: healthcheck (idempotency) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + command: '/bin/sh -v -c "sleep 10m"' + healthcheck: + test: + - CMD + - sleep + - 1 + timeout: 2s + interval: 0h0m2s3ms4us + retries: 2 + register: healthcheck_2 + +- name: healthcheck (changed) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + command: '/bin/sh -v -c "sleep 10m"' + healthcheck: + test: + - CMD + - sleep + - 1 + timeout: 3s + interval: 0h1m2s3ms4us + retries: 3 + register: healthcheck_3 + +- name: healthcheck (disabled) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + command: '/bin/sh -v -c "sleep 10m"' + healthcheck: + test: + - NONE + register: healthcheck_4 + +- name: healthcheck (disabled, idempotency) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + command: '/bin/sh -v -c "sleep 10m"' + healthcheck: + test: + - NONE + register: healthcheck_5 + +- name: healthcheck (string in healthcheck test, changed) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + command: '/bin/sh -v -c "sleep 10m"' + healthcheck: + test: "sleep 1" + register: healthcheck_6 + +- name: healthcheck (string in healthcheck test, idempotency) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + command: '/bin/sh -v -c "sleep 10m"' + healthcheck: + test: "sleep 1" + register: healthcheck_7 + +- name: healthcheck (empty) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + command: '/bin/sh -v -c "sleep 10m"' + labels: {} + register: healthcheck_8 + +- name: healthcheck (empty idempotency) + docker_swarm_service: + name: "{{ service_name }}" + image: alpine:3.8 + command: '/bin/sh -v -c "sleep 10m"' + labels: {} + register: healthcheck_9 + +- name: cleanup + docker_swarm_service: + name: "{{ service_name }}" + state: absent + diff: no + +- assert: + that: + - healthcheck_1 is changed + - healthcheck_2 is not changed + - healthcheck_3 is changed + - healthcheck_4 is changed + - healthcheck_5 is not changed + - healthcheck_6 is changed + - healthcheck_7 is not changed + - healthcheck_8 is changed + - healthcheck_9 is not changed + when: docker_py_version is version('2.4.0', '>=') +- assert: + that: + - healthcheck_1 is failed + - "('version is ' ~ docker_py_version ~'. Minimum version required is 2.4.0') in healthcheck_1.msg" + when: docker_py_version is version('2.4.0', '<') + ################################################################### ## hostname ####################################################### ################################################################### diff --git a/test/integration/targets/docker_swarm_service/vars/main.yml b/test/integration/targets/docker_swarm_service/vars/main.yml index 241172010f1..bcc81fbc5bb 100644 --- a/test/integration/targets/docker_swarm_service/vars/main.yml +++ b/test/integration/targets/docker_swarm_service/vars/main.yml @@ -12,6 +12,8 @@ service_expected_output: endpoint_mode: vip env: null force_update: null + healthcheck: null + healthcheck_disabled: null hostname: null image: busybox labels: null diff --git a/test/units/module_utils/docker/test_common.py b/test/units/module_utils/docker/test_common.py index ecf55407112..f0bae1d7c25 100644 --- a/test/units/module_utils/docker/test_common.py +++ b/test/units/module_utils/docker/test_common.py @@ -3,6 +3,8 @@ import pytest from ansible.module_utils.docker.common import ( compare_dict_allow_more_present, compare_generic, + convert_duration_to_nanosecond, + parse_healthcheck ) DICT_ALLOW_MORE_PRESENT = ( @@ -462,3 +464,52 @@ def test_dict_allow_more_present(entry): @pytest.mark.parametrize("entry", COMPARE_GENERIC) def test_compare_generic(entry): assert compare_generic(entry['a'], entry['b'], entry['method'], entry['type']) == entry['result'] + + +def test_convert_duration_to_nanosecond(): + nanoseconds = convert_duration_to_nanosecond('5s') + assert nanoseconds == 5000000000 + nanoseconds = convert_duration_to_nanosecond('1m5s') + assert nanoseconds == 65000000000 + with pytest.raises(ValueError): + convert_duration_to_nanosecond([1, 2, 3]) + with pytest.raises(ValueError): + convert_duration_to_nanosecond('10x') + + +def test_parse_healthcheck(): + result, disabled = parse_healthcheck({ + 'test': 'sleep 1', + 'interval': '1s', + }) + assert disabled is False + assert result == { + 'test': ['CMD-SHELL', 'sleep 1'], + 'interval': 1000000000 + } + + result, disabled = parse_healthcheck({ + 'test': ['NONE'], + }) + assert result is None + assert disabled + + result, disabled = parse_healthcheck({ + 'test': 'sleep 1', + 'interval': '1s423ms' + }) + assert result == { + 'test': ['CMD-SHELL', 'sleep 1'], + 'interval': 1423000000 + } + assert disabled is False + + result, disabled = parse_healthcheck({ + 'test': 'sleep 1', + 'interval': '1h1m2s3ms4us' + }) + assert result == { + 'test': ['CMD-SHELL', 'sleep 1'], + 'interval': 3662003004000 + } + assert disabled is False