diff --git a/changelogs/fragments/46772-docker_container-healthcheck.yaml b/changelogs/fragments/46772-docker_container-healthcheck.yaml new file mode 100644 index 0000000000..9e0067394b --- /dev/null +++ b/changelogs/fragments/46772-docker_container-healthcheck.yaml @@ -0,0 +1,2 @@ +minor_changes: +- "docker_container - Added support for healthcheck." diff --git a/lib/ansible/modules/cloud/docker/docker_container.py b/lib/ansible/modules/cloud/docker/docker_container.py index c6188d7d67..5b1453d890 100644 --- a/lib/ansible/modules/cloud/docker/docker_container.py +++ b/lib/ansible/modules/cloud/docker/docker_container.py @@ -147,6 +147,21 @@ options: groups: description: - List of additional group names and/or IDs that the container process will run as. + healthcheck: + version_added: "2.8" + 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(test) - Command to run to check health. C(test) 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).' + - 'I(interval) - Time between running the check. (default: 30s)' + - 'I(timeout) - Maximum time to allow one check to run. (default: 30s)' + - 'I(retries) - Consecutive failures needed to report unhealthy. It accept integer value. (default: 3)' + - 'I(start_period) - Start period for the container to initialize before starting health-retries countdown. (default: 0s)' + - 'C(interval), C(timeout) and C(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)' hostname: description: - Container hostname. @@ -659,6 +674,29 @@ EXAMPLES = ''' comparisons: '*': ignore # by default, ignore *all* options (including image) env: strict # except for environment variables; there, we want to be strict + +- name: Start container with healthstatus + docker_container: + name: nginx-proxy + image: nginx:1.13 + state: started + 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 + +- name: Remove healthcheck from container + docker_container: + name: nginx-proxy + image: nginx:1.13 + state: started + healthcheck: + # The "NONE" check needs to be specified + test: ["NONE"] ''' RETURN = ''' @@ -708,6 +746,7 @@ 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 @@ -824,6 +863,7 @@ class TaskParameters(DockerBaseClass): self.exposed_ports = None self.force_kill = None self.groups = None + self.healthcheck = None self.hostname = None self.ignore_image = None self.image = None @@ -917,6 +957,7 @@ 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() self.exp_links = None self.volume_binds = self._get_volume_binds(self.volumes) @@ -1012,6 +1053,9 @@ class TaskParameters(DockerBaseClass): if self.client.HAS_STOP_TIMEOUT_OPT: create_params['stop_timeout'] = 'stop_timeout' + if self.client.HAS_HEALTHCHECK_OPT: + create_params['healthcheck'] = 'healthcheck' + result = dict( host_config=self._host_config(), volumes=self._get_mounts(), @@ -1330,6 +1374,77 @@ 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 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 e: + 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 @@ -1406,6 +1521,7 @@ class Container(DockerBaseClass): self.parameters_map['expected_binds'] = 'volumes' self.parameters_map['expected_cmd'] = 'command' self.parameters_map['expected_devices'] = 'devices' + self.parameters_map['expected_healthcheck'] = 'healthcheck' def fail(self, msg): self.parameters.client.module.fail_json(msg=msg) @@ -1512,6 +1628,7 @@ class Container(DockerBaseClass): self.parameters.expected_env = self._get_expected_env(image) self.parameters.expected_cmd = self._get_expected_cmd() self.parameters.expected_devices = self._get_expected_devices() + self.parameters.expected_healthcheck = self._get_expected_healthcheck() if not self.container.get('HostConfig'): self.fail("has_config_diff: Error parsing container properties. HostConfig missing.") @@ -1584,6 +1701,8 @@ class Container(DockerBaseClass): volumes_from=host_config.get('VolumesFrom'), working_dir=config.get('WorkingDir'), publish_all_ports=host_config.get('PublishAllPorts'), + expected_healthcheck=config.get('Healthcheck'), + disable_healthcheck=(not config.get('Healthcheck') or config.get('Healthcheck').get('Test') == ['NONE']), ) if self.parameters.restart_policy: config_mapping['restart_retries'] = restart_policy.get('MaximumRetryCount') @@ -1966,6 +2085,16 @@ class Container(DockerBaseClass): return port + '/tcp' return port + def _get_expected_healthcheck(self): + self.log('_get_expected_healthcheck') + expected_healthcheck = dict() + + if self.parameters.healthcheck: + expected_healthcheck.update([(k.title().replace("_", ""), v) + for k, v in self.parameters.healthcheck.items()]) + + return expected_healthcheck + class ContainerManager(DockerBaseClass): ''' @@ -1978,6 +2107,8 @@ class ContainerManager(DockerBaseClass): if client.module.params.get('log_options') and not client.module.params.get('log_driver'): client.module.warn('log_options is ignored when log_driver is not specified') + if client.module.params.get('healthcheck') and not client.module.params.get('healthcheck').get('test'): + client.module.warn('healthcheck is ignored when test is not specified') if client.module.params.get('restart_retries') and not client.module.params.get('restart_policy'): client.module.warn('restart_retries is ignored when restart_policy is not specified') @@ -2385,6 +2516,9 @@ class AnsibleDockerClientContainer(AnsibleDockerClient): # Add implicit options comparisons['publish_all_ports'] = dict(type='value', comparison='strict', name='published_ports') comparisons['expected_ports'] = dict(type='dict', comparison=comparisons['published_ports']['comparison'], name='expected_ports') + comparisons['disable_healthcheck'] = dict(type='value', + comparison='ignore' if comparisons['healthcheck']['comparison'] == 'ignore' else 'strict', + name='disable_healthcheck') # Check legacy values if self.module.params['ignore_image'] and comparisons['image']['comparison'] != 'ignore': self.module.warn('The ignore_image option has been overridden by the comparisons option!') @@ -2448,11 +2582,16 @@ class AnsibleDockerClientContainer(AnsibleDockerClient): if self.module.params.get("runtime") and not runtime_supported: self.fail('docker API version is %s. Minimum version required is 1.12 to set runtime option.' % (docker_api_version,)) + healthcheck_supported = LooseVersion(docker_version) >= LooseVersion('2.0') + if self.module.params.get("healthcheck") and not healthcheck_supported: + self.fail("docker or docker-py version is %s. Minimum version required is 2.0 to set healthcheck option." % (docker_version,)) + self.HAS_INIT_OPT = init_supported self.HAS_UTS_MODE_OPT = uts_mode_supported self.HAS_BLKIO_WEIGHT_OPT = blkio_weight_supported self.HAS_CPUSET_MEMS_OPT = cpuset_mems_supported self.HAS_STOP_TIMEOUT_OPT = stop_timeout_supported + self.HAS_HEALTHCHECK_OPT = healthcheck_supported self.HAS_AUTO_REMOVE_OPT = HAS_DOCKER_PY_2 or HAS_DOCKER_PY_3 self.HAS_RUNTIME_OPT = runtime_supported @@ -2490,6 +2629,7 @@ def main(): exposed_ports=dict(type='list', aliases=['exposed', 'expose']), force_kill=dict(type='bool', default=False, aliases=['forcekill']), groups=dict(type='list'), + healthcheck=dict(type='dict'), hostname=dict(type='str'), ignore_image=dict(type='bool', default=False), image=dict(type='str'), diff --git a/test/integration/targets/docker_container/tasks/tests/options.yml b/test/integration/targets/docker_container/tasks/tests/options.yml index 54bf2d0d8e..5fc7d2a272 100644 --- a/test/integration/targets/docker_container/tasks/tests/options.yml +++ b/test/integration/targets/docker_container/tasks/tests/options.yml @@ -1182,6 +1182,133 @@ - groups_3 is not changed - groups_4 is changed +#################################################################### +## healthcheck ##################################################### +#################################################################### + +- name: healthcheck + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + healthcheck: + test: + - CMD + - sleep + - 1 + timeout: 2s + interval: 0h0m2s3ms4us + retries: 2 + stop_timeout: 1 + register: healthcheck_1 + +- name: healthcheck (idempotency) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + healthcheck: + test: + - CMD + - sleep + - 1 + timeout: 2s + interval: 0h0m2s3ms4us + retries: 2 + stop_timeout: 1 + register: healthcheck_2 + +- name: healthcheck (changed) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + healthcheck: + test: + - CMD + - sleep + - 1 + timeout: 3s + interval: 0h1m2s3ms4us + retries: 3 + stop_timeout: 1 + register: healthcheck_3 + +- name: healthcheck (no change) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + stop_timeout: 1 + register: healthcheck_4 + +- name: healthcheck (disabled) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + healthcheck: + test: + - NONE + stop_timeout: 1 + register: healthcheck_5 + +- name: healthcheck (disabled, idempotency) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + healthcheck: + test: + - NONE + stop_timeout: 1 + register: healthcheck_6 + +- name: healthcheck (string in healthcheck test, changed) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + healthcheck: + test: "sleep 1" + stop_timeout: 1 + register: healthcheck_7 + +- name: healthcheck (string in healthcheck test, idempotency) + docker_container: + image: alpine:3.8 + command: '/bin/sh -c "sleep 10m"' + name: "{{ cname }}" + state: started + healthcheck: + test: "sleep 1" + stop_timeout: 1 + register: healthcheck_8 + +- name: cleanup + docker_container: + name: "{{ cname }}" + state: absent + stop_timeout: 1 + +- assert: + that: + - healthcheck_1 is changed + - healthcheck_2 is not changed + - healthcheck_3 is changed + - healthcheck_4 is not changed + - healthcheck_5 is changed + - healthcheck_6 is not changed + - healthcheck_7 is changed + - healthcheck_8 is not changed + #################################################################### ## hostname ######################################################## ####################################################################