Fix ctrl+c in pause module and add tests (#40134)

* Fix all cases with pause and ctrl+c
 - naked:
	- pause:
 - with prompt
	- pause: prompt=hi
 - time wait
	- pause: seconds=60
 - time wait with prompt
	- pause: seconds=60 prompt=hi


Fixes #35372

* Use curses to control stdout
* Use curses to clear lines on interactive input
* Validate input for echo parameter and fail nicely if invalid
* Add integration tests for pause module using pexpect
* Use try except when trying to determine erase sequence to account for lack of TTY in containers in tests
* Improve output validation for regular paus test
* Accept two digit precision for pause length in test
* Check for seconds when seconds is specificed, minutes when minutes is specified
* Add test for no TTY mode

Co-authored by: Toshio Kuratomi <a.badger@gmail.com>
Co-authored by: Brian Coca <brian.coca+git@gmail.com>
pull/40282/merge
Sam Doran 6 years ago committed by GitHub
parent 39f9d3e4a6
commit 1c20029694
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23

@ -0,0 +1,2 @@
bugfixes:
- pause - ensure ctrl+c interrupt works in all cases (https://github.com/ansible/ansible/issues/35372)

@ -19,14 +19,16 @@ __metaclass__ = type
import datetime import datetime
import signal import signal
import sys
import termios import termios
import time import time
import tty import tty
from os import isatty from os import isatty
from ansible.errors import AnsibleError from ansible.errors import AnsibleError
from ansible.module_utils._text import to_text, to_native
from ansible.module_utils.parsing.convert_bool import boolean
from ansible.module_utils.six import PY3 from ansible.module_utils.six import PY3
from ansible.module_utils._text import to_text
from ansible.plugins.action import ActionBase from ansible.plugins.action import ActionBase
try: try:
@ -35,6 +37,20 @@ except ImportError:
from ansible.utils.display import Display from ansible.utils.display import Display
display = Display() display = Display()
try:
import curses
curses.setupterm()
HAS_CURSES = True
except (ImportError, curses.error):
HAS_CURSES = False
if HAS_CURSES:
MOVE_TO_BOL = curses.tigetstr('cr')
CLEAR_TO_EOL = curses.tigetstr('el')
else:
MOVE_TO_BOL = b'\r'
CLEAR_TO_EOL = b'\x1b[K'
class AnsibleTimeoutExceeded(Exception): class AnsibleTimeoutExceeded(Exception):
pass pass
@ -44,6 +60,11 @@ def timeout_handler(signum, frame):
raise AnsibleTimeoutExceeded raise AnsibleTimeoutExceeded
def clear_line(stdout):
stdout.write(b'\x1b[%s' % MOVE_TO_BOL)
stdout.write(b'\x1b[%s' % CLEAR_TO_EOL)
class ActionModule(ActionBase): class ActionModule(ActionBase):
''' pauses execution for a length or time, or until input is received ''' ''' pauses execution for a length or time, or until input is received '''
@ -81,10 +102,11 @@ class ActionModule(ActionBase):
# Should keystrokes be echoed to stdout? # Should keystrokes be echoed to stdout?
if 'echo' in self._task.args: if 'echo' in self._task.args:
echo = self._task.args['echo'] try:
if not type(echo) == bool: echo = boolean(self._task.args['echo'])
except TypeError as e:
result['failed'] = True result['failed'] = True
result['msg'] = "'%s' is not a valid setting for 'echo'." % self._task.args['echo'] result['msg'] = to_native(e)
return result return result
# Add a note saying the output is hidden if echo is disabled # Add a note saying the output is hidden if echo is disabled
@ -96,7 +118,7 @@ class ActionModule(ActionBase):
prompt = "[%s]\n%s%s:" % (self._task.get_name().strip(), self._task.args['prompt'], echo_prompt) prompt = "[%s]\n%s%s:" % (self._task.get_name().strip(), self._task.args['prompt'], echo_prompt)
else: else:
# If no custom prompt is specified, set a default prompt # If no custom prompt is specified, set a default prompt
prompt = "[%s]\n%s%s:" % (self._task.get_name().strip(), 'Press enter to continue', echo_prompt) prompt = "[%s]\n%s%s:" % (self._task.get_name().strip(), 'Press enter to continue, Ctrl+C to interrupt', echo_prompt)
# Are 'minutes' or 'seconds' keys that exist in 'args'? # Are 'minutes' or 'seconds' keys that exist in 'args'?
if 'minutes' in self._task.args or 'seconds' in self._task.args: if 'minutes' in self._task.args or 'seconds' in self._task.args:
@ -149,65 +171,83 @@ class ActionModule(ActionBase):
try: try:
if PY3: if PY3:
stdin = self._connection._new_stdin.buffer stdin = self._connection._new_stdin.buffer
stdout = sys.stdout.buffer
else: else:
stdin = self._connection._new_stdin stdin = self._connection._new_stdin
stdout = sys.stdout
fd = stdin.fileno() fd = stdin.fileno()
except (ValueError, AttributeError): except (ValueError, AttributeError):
# ValueError: someone is using a closed file descriptor as stdin # ValueError: someone is using a closed file descriptor as stdin
# AttributeError: someone is using a null file descriptor as stdin on windoez # AttributeError: someone is using a null file descriptor as stdin on windoez
stdin = None stdin = None
if fd is not None: if fd is not None:
if isatty(fd): if isatty(fd):
# grab actual Ctrl+C sequence
try:
intr = termios.tcgetattr(fd)[6][termios.VINTR]
except Exception:
# unsupported/not present, use default
intr = b'\x03' # value for Ctrl+C
# get backspace sequences
try:
backspace = termios.tcgetattr(fd)[6][termios.VERASE]
except Exception:
backspace = [b'\x7f', b'\x08']
old_settings = termios.tcgetattr(fd) old_settings = termios.tcgetattr(fd)
tty.setraw(fd) tty.setraw(fd)
tty.setraw(stdout.fileno())
# Enable a few things turned off by tty.setraw() # Only echo input if no timeout is specified
# ICANON -> Allows characters to be deleted and hides things like ^M. if not seconds and echo:
# ICRNL -> Makes the return key work when ICANON is enabled, otherwise
# you get stuck at the prompt with no way to get out of it.
# See man termios for details on these flags
if not seconds:
new_settings = termios.tcgetattr(fd) new_settings = termios.tcgetattr(fd)
new_settings[0] = new_settings[0] | termios.ICRNL new_settings[3] = new_settings[3] | termios.ECHO
new_settings[3] = new_settings[3] | termios.ICANON
termios.tcsetattr(fd, termios.TCSANOW, new_settings) termios.tcsetattr(fd, termios.TCSANOW, new_settings)
if echo:
# Enable ECHO since tty.setraw() disables it
new_settings = termios.tcgetattr(fd)
new_settings[3] = new_settings[3] | termios.ECHO
termios.tcsetattr(fd, termios.TCSANOW, new_settings)
# flush the buffer to make sure no previous key presses # flush the buffer to make sure no previous key presses
# are read in below # are read in below
termios.tcflush(stdin, termios.TCIFLUSH) termios.tcflush(stdin, termios.TCIFLUSH)
while True: while True:
try: try:
if fd is not None: if fd is not None:
key_pressed = stdin.read(1) key_pressed = stdin.read(1)
if key_pressed == intr: # value for Ctrl+C
if seconds: clear_line(stdout)
if key_pressed == b'\x03': raise KeyboardInterrupt
raise KeyboardInterrupt
if not seconds: if not seconds:
if fd is None or not isatty(fd): if fd is None or not isatty(fd):
display.warning("Not waiting from prompt as stdin is not interactive") display.warning("Not waiting for response to prompt as stdin is not interactive")
break break
# read key presses and act accordingly # read key presses and act accordingly
if key_pressed in (b'\r', b'\n'): if key_pressed in (b'\r', b'\n'):
clear_line(stdout)
break break
elif key_pressed in backspace:
# delete a character if backspace is pressed
result['user_input'] = result['user_input'][:-1]
clear_line(stdout)
if echo:
stdout.write(result['user_input'])
stdout.flush()
else: else:
result['user_input'] += key_pressed result['user_input'] += key_pressed
except KeyboardInterrupt: except KeyboardInterrupt:
if seconds is not None: signal.alarm(0)
signal.alarm(0)
display.display("Press 'C' to continue the play or 'A' to abort \r"), display.display("Press 'C' to continue the play or 'A' to abort \r"),
if self._c_or_a(stdin): if self._c_or_a(stdin):
clear_line(stdout)
break break
else:
raise AnsibleError('user requested abort!') clear_line(stdout)
raise AnsibleError('user requested abort!')
except AnsibleTimeoutExceeded: except AnsibleTimeoutExceeded:
# this is the exception we expect when the alarm signal # this is the exception we expect when the alarm signal

@ -0,0 +1,11 @@
- name: Test pause module in default state
hosts: testhost
become: no
gather_facts: no
tasks:
- name: EXPECTED FAILURE
pause:
- debug:
msg: Task after pause

@ -0,0 +1,12 @@
- name: Test pause module with custom prompt
hosts: testhost
become: no
gather_facts: no
tasks:
- name: EXPECTED FAILURE
pause:
prompt: Custom prompt
- debug:
msg: Task after pause

@ -0,0 +1,12 @@
- name: Test pause module with pause
hosts: testhost
become: no
gather_facts: no
tasks:
- name: EXPECTED FAILURE
pause:
seconds: 2
- debug:
msg: Task after pause

@ -0,0 +1,13 @@
- name: Test pause module with pause and custom prompt
hosts: testhost
become: no
gather_facts: no
tasks:
- name: EXPECTED FAILURE
pause:
seconds: 2
prompt: Waiting for two seconds
- debug:
msg: Task after pause

@ -0,0 +1,35 @@
- name: Test pause module echo output
hosts: testhost
become: no
gather_facts: no
tasks:
- pause:
echo: yes
prompt: Enter some text
register: results
- name: Ensure that input was captured
assert:
that:
- results.user_input == 'hello there'
- pause:
echo: yes
prompt: Enter some text to edit
register: result
- name: Ensure edited input was captured
assert:
that:
- result.user_input == 'hello tommy boy'
- pause:
echo: no
prompt: Enter some text
register: result
- name: Ensure secret input was caputered
assert:
that:
- result.user_input == 'supersecretpancakes'

@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -eux
# Test pause module when no tty and non-interactive. This is to prevent playbooks
# from hanging in cron and Tower jobs.
/usr/bin/env bash << EOF
ansible-playbook test-pause-no-tty.yml -i ../../inventory 2>&1 | \
grep '\[WARNING\]: Not waiting for response to prompt as stdin is not interactive' && {
echo 'Successfully skipped pause in no TTY mode' >&2
exit 0
} || {
echo 'Failed to skip pause module' >&2
exit 1
}
EOF
# Test pause with seconds and minutes specified
ansible-playbook test-pause.yml -i ../../inventory "$@"
# Interactively test pause
pip install pexpect
python test-pause.py -i ../../inventory "$@"

@ -0,0 +1,7 @@
- name: Test pause
hosts: testhost
gather_facts: no
become: no
tasks:
- pause:

@ -0,0 +1,270 @@
#!/usr/bin/env python
import os
import pexpect
import sys
import termios
from ansible.module_utils.six import PY2
args = sys.argv[1:]
env_vars = {
'ANSIBLE_ROLES_PATH': './roles',
'ANSIBLE_NOCOLOR': 'True',
'ANSIBLE_RETRY_FILES_ENABLED': 'False'
}
try:
backspace = termios.tcgetattr(sys.stdin.fileno())[6][termios.VERASE]
except Exception:
backspace = b'\x7f'
if PY2:
log_buffer = sys.stdout
else:
log_buffer = sys.stdout.buffer
os.environ.update(env_vars)
# -- Plain pause -- #
playbook = 'pause-1.yml'
# Case 1 - Contiune with enter
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Press enter to continue, Ctrl\+C to interrupt:')
pause_test.send('\r')
pause_test.expect('Task after pause')
pause_test.expect(pexpect.EOF)
pause_test.close()
# Case 2 - Continue with C
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Press enter to continue, Ctrl\+C to interrupt:')
pause_test.send('\x03')
pause_test.expect("Press 'C' to continue the play or 'A' to abort")
pause_test.send('C')
pause_test.expect('Task after pause')
pause_test.expect(pexpect.EOF)
pause_test.close()
# Case 3 - Abort with A
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Press enter to continue, Ctrl\+C to interrupt:')
pause_test.send('\x03')
pause_test.expect("Press 'C' to continue the play or 'A' to abort")
pause_test.send('A')
pause_test.expect('user requested abort!')
pause_test.expect(pexpect.EOF)
pause_test.close()
# -- Custom Prompt -- #
playbook = 'pause-2.yml'
# Case 1 - Contiune with enter
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Custom prompt:')
pause_test.send('\r')
pause_test.expect('Task after pause')
pause_test.expect(pexpect.EOF)
pause_test.close()
# Case 2 - Contiune with C
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Custom prompt:')
pause_test.send('\x03')
pause_test.expect("Press 'C' to continue the play or 'A' to abort")
pause_test.send('C')
pause_test.expect('Task after pause')
pause_test.expect(pexpect.EOF)
pause_test.close()
# Case 3 - Abort with A
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Custom prompt:')
pause_test.send('\x03')
pause_test.expect("Press 'C' to continue the play or 'A' to abort")
pause_test.send('A')
pause_test.expect('user requested abort!')
pause_test.expect(pexpect.EOF)
pause_test.close()
# -- Pause for N seconds -- #
playbook = 'pause-3.yml'
# Case 1 - Wait for task to continue after timeout
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Pausing for \d+ seconds')
pause_test.expect(r"\(ctrl\+C then 'C' = continue early, ctrl\+C then 'A' = abort\)")
pause_test.expect('Task after pause')
pause_test.expect(pexpect.EOF)
pause_test.close()
# Case 2 - Contiune with Ctrl + C, C
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Pausing for \d+ seconds')
pause_test.expect(r"\(ctrl\+C then 'C' = continue early, ctrl\+C then 'A' = abort\)")
pause_test.send('\x03')
pause_test.send('C')
pause_test.expect('Task after pause')
pause_test.expect(pexpect.EOF)
pause_test.close()
# Case 3 - Abort with Ctrl + C, A
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Pausing for \d+ seconds')
pause_test.expect(r"\(ctrl\+C then 'C' = continue early, ctrl\+C then 'A' = abort\)")
pause_test.send('\x03')
pause_test.send('A')
pause_test.expect('user requested abort!')
pause_test.expect(pexpect.EOF)
pause_test.close()
# -- Pause for N seconds with custom prompt -- #
playbook = 'pause-4.yml'
# Case 1 - Wait for task to continue after timeout
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Pausing for \d+ seconds')
pause_test.expect(r"\(ctrl\+C then 'C' = continue early, ctrl\+C then 'A' = abort\)")
pause_test.expect(r"Waiting for two seconds:")
pause_test.expect('Task after pause')
pause_test.expect(pexpect.EOF)
pause_test.close()
# Case 2 - Contiune with Ctrl + C, C
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Pausing for \d+ seconds')
pause_test.expect(r"\(ctrl\+C then 'C' = continue early, ctrl\+C then 'A' = abort\)")
pause_test.expect(r"Waiting for two seconds:")
pause_test.send('\x03')
pause_test.send('C')
pause_test.expect('Task after pause')
pause_test.expect(pexpect.EOF)
pause_test.close()
# Case 3 - Abort with Ctrl + C, A
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Pausing for \d+ seconds')
pause_test.expect(r"\(ctrl\+C then 'C' = continue early, ctrl\+C then 'A' = abort\)")
pause_test.expect(r"Waiting for two seconds:")
pause_test.send('\x03')
pause_test.send('A')
pause_test.expect('user requested abort!')
pause_test.expect(pexpect.EOF)
pause_test.close()
# -- Enter input and ensure it's caputered, echoed, and can be edited -- #
playbook = 'pause-5.yml'
pause_test = pexpect.spawn(
'ansible-playbook',
args=[playbook] + args,
timeout=10,
env=os.environ
)
pause_test.logfile = log_buffer
pause_test.expect(r'Enter some text:')
pause_test.sendline('hello there')
pause_test.expect(r'Enter some text to edit:')
pause_test.send('hello there')
pause_test.send(backspace * 4)
pause_test.send('ommy boy\r')
pause_test.expect(r'Enter some text \(output is hidden\):')
pause_test.sendline('supersecretpancakes')
pause_test.expect(pexpect.EOF)
pause_test.close()

@ -0,0 +1,21 @@
- name: Test pause
hosts: testhost
gather_facts: no
become: no
tasks:
- pause:
seconds: 1
register: results
- assert:
that:
- results.stdout is search('Paused for \d+\.\d+ seconds')
- pause:
minutes: 1
register: results
- assert:
that:
- results.stdout is search('Paused for \d+\.\d+ minutes')
Loading…
Cancel
Save