From df4e83deda3f6ade76c2f473137ecbfd7f88d9a4 Mon Sep 17 00:00:00 2001 From: Brian Coca Date: Tue, 19 May 2020 17:20:08 -0400 Subject: [PATCH] added 'task timeout' feature (#69284) * added 'task timeout' feature Co-authored-by: Abhijeet Kasurde --- docs/docsite/keyword_desc.yml | 1 + lib/ansible/config/base.yml | 11 +++++++++++ lib/ansible/executor/task_executor.py | 18 ++++++++++++++++++ lib/ansible/playbook/base.py | 1 + test/integration/targets/playbook/runme.sh | 3 +++ test/integration/targets/playbook/timeout.yml | 12 ++++++++++++ 6 files changed, 46 insertions(+) create mode 100644 test/integration/targets/playbook/timeout.yml diff --git a/docs/docsite/keyword_desc.yml b/docs/docsite/keyword_desc.yml index 361242d959a..548142bdc8c 100644 --- a/docs/docsite/keyword_desc.yml +++ b/docs/docsite/keyword_desc.yml @@ -68,6 +68,7 @@ serial: | strategy: Allows you to choose the connection plugin to use for the play. tags: Tags applied to the task or included tasks, this allows selecting subsets of tasks from the command line. tasks: Main list of tasks to execute in the play, they run after :term:`roles` and before :term:`post_tasks`. +timeout: Time limit for task to execute in, if exceeded Ansible will interrupt and fail the task. throttle: Limit number of concurrent task runs on task, block and playbook level. This is independent of the forks and serial settings, but cannot be set higher than those limits. For example, if forks is set to 10 and the throttle is set to 15, at most 10 hosts will be operated on in parallel. until: "This keyword implies a ':term:`retries` loop' that will go on until the condition supplied here is met or we hit the :term:`retries` limit." vars: Dictionary/map of variables diff --git a/lib/ansible/config/base.yml b/lib/ansible/config/base.yml index f76088e7b7c..4a5bae8e686 100644 --- a/lib/ansible/config/base.yml +++ b/lib/ansible/config/base.yml @@ -1844,6 +1844,17 @@ TAGS_SKIP: ini: - {key: skip, section: tags} version_added: "2.5" +TASK_TIMEOUT: + name: Task Timeout + default: 0 + description: + - Set the maximum time (in seconds) that a task can run for. + - If set to 0 (the default) there is no timeout. + env: [{name: ANSIBLE_TASK_TIMEOUT}] + ini: + - {key: task_timeout, section: defaults} + type: integer + version_added: '2.10' WORKER_SHUTDOWN_POLL_COUNT: name: Worker Shutdown Poll Count default: 0 diff --git a/lib/ansible/executor/task_executor.py b/lib/ansible/executor/task_executor.py index c9332a7d2f8..589b1a5964d 100644 --- a/lib/ansible/executor/task_executor.py +++ b/lib/ansible/executor/task_executor.py @@ -9,6 +9,7 @@ import re import pty import time import json +import signal import subprocess import sys import termios @@ -39,6 +40,14 @@ display = Display() __all__ = ['TaskExecutor'] +class TaskTimeoutError(BaseException): + pass + + +def task_timeout(signum, frame): + raise TaskTimeoutError + + def remove_omit(task_args, omit_token): ''' Remove args with a value equal to the ``omit_token`` recursively @@ -651,6 +660,9 @@ class TaskExecutor: for attempt in xrange(1, retries + 1): display.debug("running the handler") try: + if self._task.timeout: + old_sig = signal.signal(signal.SIGALRM, task_timeout) + signal.alarm(self._task.timeout) result = self._handler.run(task_vars=variables) except AnsibleActionSkip as e: return dict(skipped=True, msg=to_text(e)) @@ -658,7 +670,13 @@ class TaskExecutor: return dict(failed=True, msg=to_text(e)) except AnsibleConnectionFailure as e: return dict(unreachable=True, msg=to_text(e)) + except TaskTimeoutError as e: + msg = 'The %s action failed to execute in the expected time frame (%d) and was terminated' % (self._task.action, self._task.timeout) + return dict(failed=True, msg=msg) finally: + if self._task.timeout: + signal.alarm(0) + old_sig = signal.signal(signal.SIGALRM, old_sig) self._handler.cleanup() display.debug("handler run complete") diff --git a/lib/ansible/playbook/base.py b/lib/ansible/playbook/base.py index 7fc6d3b99c5..df04592831a 100644 --- a/lib/ansible/playbook/base.py +++ b/lib/ansible/playbook/base.py @@ -614,6 +614,7 @@ class Base(FieldAttributeBase): _diff = FieldAttribute(isa='bool', default=context.cliargs_deferred_get('diff')) _any_errors_fatal = FieldAttribute(isa='bool', default=C.ANY_ERRORS_FATAL) _throttle = FieldAttribute(isa='int', default=0) + _timeout = FieldAttribute(isa='int', default=C.TASK_TIMEOUT) # explicitly invoke a debugger on tasks _debugger = FieldAttribute(isa='string') diff --git a/test/integration/targets/playbook/runme.sh b/test/integration/targets/playbook/runme.sh index bba7368e6b0..25e2e5a64d3 100755 --- a/test/integration/targets/playbook/runme.sh +++ b/test/integration/targets/playbook/runme.sh @@ -4,3 +4,6 @@ set -eux # run type tests ansible-playbook -i ../../inventory types.yml -v "$@" + +# test timeout +ansible-playbook -i ../../inventory timeout.yml -v "$@" diff --git a/test/integration/targets/playbook/timeout.yml b/test/integration/targets/playbook/timeout.yml new file mode 100644 index 00000000000..d576fa80a42 --- /dev/null +++ b/test/integration/targets/playbook/timeout.yml @@ -0,0 +1,12 @@ +- hosts: localhost + gather_facts: false + tasks: + - shell: sleep 100 + timeout: 1 + ignore_errors: true + register: time + + - assert: + that: + - time is failed + - '"The command action failed to execute in the expected time frame" in time["msg"]'