Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

docker_container: allow to wait for a container to become healthy #921

Merged
merged 3 commits into from
Jul 9, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions changelogs/fragments/921-docker_container-healthy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
minor_changes:
- "docker_container - the new ``state=healthy`` allows to wait for a container to become healthy on startup.
The ``healthy_wait_timeout`` option allows to configure the maximum time to wait for this to happen
(https://github.com/ansible-collections/community.docker/issues/890, https://github.com/ansible-collections/community.docker/pull/921)."
44 changes: 32 additions & 12 deletions plugins/module_utils/module_container/module.py
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,9 @@ def __init__(self, module, engine_driver, client, active_options):
self.param_pull_check_mode_behavior = self.module.params['pull_check_mode_behavior']
self.param_recreate = self.module.params['recreate']
self.param_removal_wait_timeout = self.module.params['removal_wait_timeout']
self.param_healthy_wait_timeout = self.module.params['healthy_wait_timeout']
if self.param_healthy_wait_timeout <= 0:
self.param_healthy_wait_timeout = None
self.param_restart = self.module.params['restart']
self.param_state = self.module.params['state']
self._parse_comparisons()
Expand Down Expand Up @@ -212,7 +215,7 @@ def fail(self, *args, **kwargs):
self.client.fail(*args, **kwargs)

def run(self):
if self.param_state in ('stopped', 'started', 'present'):
if self.param_state in ('stopped', 'started', 'present', 'healthy'):
self.present(self.param_state)
elif self.param_state == 'absent':
self.absent()
Expand All @@ -227,29 +230,32 @@ def run(self):
if self.facts:
self.results['container'] = self.facts

def wait_for_state(self, container_id, complete_states=None, wait_states=None, accept_removal=False, max_wait=None):
def wait_for_state(self, container_id, complete_states=None, wait_states=None, accept_removal=False, max_wait=None, health_state=False):
delay = 1.0
total_wait = 0
while True:
# Inspect container
result = self.engine_driver.inspect_container_by_id(self.client, container_id)
if result is None:
if accept_removal:
return
return result
msg = 'Encontered vanished container while waiting for container "{0}"'
self.fail(msg.format(container_id))
# Check container state
state = result.get('State', {}).get('Status')
state_info = result.get('State') or {}
if health_state:
state_info = state_info.get('Health') or {}
state = state_info.get('Status')
if complete_states is not None and state in complete_states:
return
return result
if wait_states is not None and state not in wait_states:
msg = 'Encontered unexpected state "{1}" while waiting for container "{0}"'
self.fail(msg.format(container_id, state))
self.fail(msg.format(container_id, state), container=result)
# Wait
if max_wait is not None:
if total_wait > max_wait or delay < 1E-4:
msg = 'Timeout of {1} seconds exceeded while waiting for container "{0}"'
self.fail(msg.format(container_id, max_wait))
self.fail(msg.format(container_id, max_wait), container=result)
if total_wait + delay > max_wait:
delay = max_wait - total_wait
sleep(delay)
Expand Down Expand Up @@ -368,10 +374,10 @@ def present(self, state):
container = self.update_limits(container, container_image, comparison_image, host_info)
container = self.update_networks(container, container_created)

if state == 'started' and not container.running:
if state in ('started', 'healthy') and not container.running:
self.diff_tracker.add('running', parameter=True, active=was_running)
container = self.container_start(container.id)
elif state == 'started' and self.param_restart:
elif state in ('started', 'healthy') and self.param_restart:
self.diff_tracker.add('running', parameter=True, active=was_running)
self.diff_tracker.add('restarted', parameter=True, active=False)
container = self.container_restart(container.id)
Expand All @@ -380,7 +386,7 @@ def present(self, state):
self.container_stop(container.id)
container = self._get_container(container.id)

if state == 'started' and self.param_paused is not None and container.paused != self.param_paused:
if state in ('started', 'healthy') and self.param_paused is not None and container.paused != self.param_paused:
self.diff_tracker.add('paused', parameter=self.param_paused, active=was_paused)
if not self.check_mode:
try:
Expand All @@ -398,6 +404,19 @@ def present(self, state):

self.facts = container.raw

if state == 'healthy' and not self.check_mode:
# `None` means that no health check enabled; simply treat this as 'healthy'
inspect_result = self.wait_for_state(
container.id,
wait_states=['starting', 'unhealthy'],
complete_states=['healthy', None],
max_wait=self.param_healthy_wait_timeout,
health_state=True,
)
if inspect_result:
# Return the latest inspection results retrieved
self.facts = inspect_result

def absent(self):
container = self._get_container(self.param_name)
if container.exists:
Expand Down Expand Up @@ -878,10 +897,11 @@ def run_module(engine_driver):
recreate=dict(type='bool', default=False),
removal_wait_timeout=dict(type='float'),
restart=dict(type='bool', default=False),
state=dict(type='str', default='started', choices=['absent', 'present', 'started', 'stopped']),
state=dict(type='str', default='started', choices=['absent', 'present', 'healthy', 'started', 'stopped']),
healthy_wait_timeout=dict(type='float', default=300),
),
required_if=[
('state', 'present', ['image'])
('state', 'present', ['image']),
],
)

Expand Down
17 changes: 17 additions & 0 deletions plugins/modules/docker_container.py
Original file line number Diff line number Diff line change
Expand Up @@ -381,6 +381,7 @@
- "O(healthcheck.interval), O(healthcheck.timeout), O(healthcheck.start_period), and O(healthcheck.start_interval) are specified as durations.
They accept duration as a string in a format that look like: V(5h34m56s), V(1m30s), and so on.
The supported units are V(us), V(ms), V(s), V(m) and V(h)."
- See also O(state=healthy).
type: dict
suboptions:
test:
Expand Down Expand Up @@ -919,6 +920,11 @@
with the requested config.'
- 'V(started) - Asserts that the container is first V(present), and then if the container is not running moves it to a running
state. Use O(restart) to force a matching container to be stopped and restarted.'
- V(healthy) - Asserts that the container is V(present) and V(started), and is actually healthy as well.
felixfontein marked this conversation as resolved.
Show resolved Hide resolved
This means that the conditions defined in O(healthcheck) respectively in the image's C(HEALTHCHECK)
(L(Docker reference for HEALTHCHECK, https://docs.docker.com/reference/dockerfile/#healthcheck))
are satisfied.
The time waited can be controlled with O(healthy_wait_timeout). This state has been added in community.docker 3.11.0.
- 'V(stopped) - Asserts that the container is first V(present), and then if the container is running moves it to a stopped
state.'
- "To control what will be taken into account when comparing configuration, see the O(comparisons) option. To avoid that the
Expand All @@ -932,12 +938,23 @@
choices:
- absent
- present
- healthy
- stopped
- started
stop_signal:
description:
- Override default signal used to stop the container.
type: str
healthy_wait_timeout:
description:
- When waiting for the container to become healthy if O(state=healthy), this option controls how long
the module waits until the container state becomes healthy.
- The timeout is specified in seconds. The default, V(300), is 5 minutes.
- Set this to 0 or a negative value to wait indefinitely.
Note that depending on the container this can result in the module not terminating.
default: 300
type: float
version_added: 3.11.0
stop_timeout:
description:
- Number of seconds to wait for the container to stop before sending C(SIGKILL).
Expand Down
64 changes: 64 additions & 0 deletions tests/integration/targets/docker_container/tasks/tests/healthy.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
---
# Copyright (c) Ansible Project
# GNU General Public License v3.0+ (see LICENSES/GPL-3.0-or-later.txt or https://www.gnu.org/licenses/gpl-3.0.txt)
# SPDX-License-Identifier: GPL-3.0-or-later

- name: Registering container name
set_fact:
cname: "{{ cname_prefix ~ '-hi' }}"
- name: Registering container name
set_fact:
cnames: "{{ cnames + [cname] }}"

- name: Prepare container
docker_container:
name: "{{ cname }}"
image: "{{ docker_test_image_healthcheck }}"
command: '10m'
state: stopped
register: healthy_1

- debug: var=healthy_1.container.State

- name: Start container (not healthy in time)
docker_container:
name: "{{ cname }}"
state: healthy
healthy_wait_timeout: 1
register: healthy_2
ignore_errors: true

- debug: var=healthy_2.container.State

- name: Prepare container
docker_container:
name: "{{ cname }}"
image: "{{ docker_test_image_healthcheck }}"
command: '10m 5s'
state: stopped
force_kill: true
register: healthy_3

- debug: var=healthy_3.container.State

- name: Start container (healthy in time)
docker_container:
name: "{{ cname }}"
state: healthy
healthy_wait_timeout: 10
register: healthy_4

- debug: var=healthy_4.container.State

- name: Cleanup
docker_container:
name: "{{ cname }}"
state: absent
force_kill: true
- assert:
that:
- healthy_2 is failed
- healthy_2.container.State.Health.Status == "starting"
- healthy_2.msg.startswith("Timeout of 1.0 seconds exceeded while waiting for container ")
- healthy_4 is changed
- healthy_4.container.State.Health.Status == "healthy"
Loading