Repository: ansible/ansible

Issue:
pause play fails with exception when output is redirected
##### SUMMARY
When the 'pause' module is used in a playbook and the ansible-playbook stdout is redirected, the play will fail with an exception. This is causing scripts that call the playbook to fail, but can be reproduced interactively.

```
TASK [pause] *******************************************************************************************************************************************
task path: /tmp/playbook.yml:6
Pausing for 5 seconds
(ctrl+C then 'C' = continue early, ctrl+C then 'A' = abort)
The full traceback is:
Traceback (most recent call last):
  File "/usr/lib/python2.7/site-packages/ansible/executor/task_executor.py", line 138, in run
    res = self._execute()
  File "/usr/lib/python2.7/site-packages/ansible/executor/task_executor.py", line 561, in _execute
    result = self._handler.run(task_vars=variables)
  File "/usr/lib/python2.7/site-packages/ansible/plugins/action/pause.py", line 202, in run
    tty.setraw(stdout.fileno())
  File "/usr/lib64/python2.7/tty.py", line 20, in setraw
    mode = tcgetattr(fd)
error: (25, 'Inappropriate ioctl for device')
fatal: [localhost]: FAILED! => {
    "msg": "Unexpected failure during module execution.", 
    "stdout": ""
}
```

##### ISSUE TYPE
 - Bug Report

##### COMPONENT NAME
pause

##### ANSIBLE VERSION
```
ansible 2.5.4
  config file = /etc/ansible/ansible.cfg
  configured module search path = [u'/root/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python2.7/site-packages/ansible
  executable location = /usr/bin/ansible
  python version = 2.7.5 (default, May  3 2017, 07:55:04) [GCC 4.8.5 20150623 (Red Hat 4.8.5-14)]
```

##### CONFIGURATION
```
DEFAULT_ROLES_PATH(/etc/ansible/ansible.cfg) = [u'/etc/ansible/roles', u'/usr/share/ansible/roles']
```

##### OS / ENVIRONMENT
```
[root@g1-3 tmp]# cat /etc/redhat-release 
Red Hat Enterprise Linux Server release 7.4 (Maipo)
[root@g1-3 tmp]# uname -a
Linux g1-3.example.com 3.10.0-693.5.2.el7.x86_64 #1 SMP Fri Oct 13 10:46:25 EDT 2017 x86_64 x86_64 x86_64 GNU/Linux
```

##### STEPS TO REPRODUCE
<!--- For bugs, show exactly how to reproduce the problem, using a minimal test-case.
For new features, show how the feature would be used. -->

<!--- Paste example playbooks or commands between quotes below -->
```yaml
---
- hosts: all
  gather_facts: no

  tasks:
    - pause:
        seconds: 5

```
```
# ansible-playbook -i localhost, playbook.yml
...
# echo $?
0
```
```
# ansible-playbook -i localhost, playbook.yml > /dev/null
# echo $?
2
```

<!--- You can also paste gist.github.com links for larger files -->

##### EXPECTED RESULTS
The pause play should complete without exception

##### ACTUAL RESULTS
The pause play consistently fails with exception. This has happened with any stdout redirection I have tested (to real files, not just /dev/null).

```
# ansible-playbook -vvv -i localhost, playbook.yml | tee -a /dev/null
ansible-playbook 2.5.4
  config file = /etc/ansible/ansible.cfg
  configured module search path = [u'/root/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python2.7/site-packages/ansible
  executable location = /usr/bin/ansible-playbook
  python version = 2.7.5 (default, May  3 2017, 07:55:04) [GCC 4.8.5 20150623 (Red Hat 4.8.5-14)]
Using /etc/ansible/ansible.cfg as config file
Parsed localhost, inventory source with host_list plugin

PLAYBOOK: playbook.yml *********************************************************************************************************************************
1 plays in playbook.yml

PLAY [all] *********************************************************************************************************************************************
META: ran handlers

TASK [pause] *******************************************************************************************************************************************
task path: /tmp/playbook.yml:6
Pausing for 5 seconds
(ctrl+C then 'C' = continue early, ctrl+C then 'A' = abort)
The full traceback is:
Traceback (most recent call last):
  File "/usr/lib/python2.7/site-packages/ansible/executor/task_executor.py", line 138, in run
    res = self._execute()
  File "/usr/lib/python2.7/site-packages/ansible/executor/task_executor.py", line 561, in _execute
    result = self._handler.run(task_vars=variables)
  File "/usr/lib/python2.7/site-packages/ansible/plugins/action/pause.py", line 202, in run
    tty.setraw(stdout.fileno())
  File "/usr/lib64/python2.7/tty.py", line 20, in setraw
    mode = tcgetattr(fd)
error: (25, 'Inappropriate ioctl for device')
fatal: [localhost]: FAILED! => {
    "msg": "Unexpected failure during module execution.", 
    "stdout": ""
}

NO MORE HOSTS LEFT *************************************************************************************************************************************
	to retry, use: --limit @/tmp/playbook.retry

PLAY RECAP *********************************************************************************************************************************************
localhost                  : ok=0    changed=0    unreachable=0    failed=1   
```



Discussion:
This did not occur with 2.5.3, but occurs with both 2.5.4 and 2.5.5
cc @tbielawa
[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: notify --->
Testing confirms that this regression was introduced with 1c20029694b647c90612b5116bb619d806bf2aae
cc @samdoran 

Resolving patch:
diff --git a/lib/ansible/plugins/action/pause.py b/lib/ansible/plugins/action/pause.py
--- a/lib/ansible/plugins/action/pause.py
+++ b/lib/ansible/plugins/action/pause.py
@@ -143,7 +143,7 @@ def run(self, tmp=None, task_vars=None):
         result['start'] = to_text(datetime.datetime.now())
         result['user_input'] = b''
 
-        fd = None
+        stdin_fd = None
         old_settings = None
         try:
             if seconds is not None:
@@ -167,7 +167,8 @@ def run(self, tmp=None, task_vars=None):
 
             # save the attributes on the existing (duped) stdin so
             # that we can restore them later after we set raw mode
-            fd = None
+            stdin_fd = None
+            stdout_fd = None
             try:
                 if PY3:
                     stdin = self._connection._new_stdin.buffer
@@ -175,52 +176,59 @@ def run(self, tmp=None, task_vars=None):
                 else:
                     stdin = self._connection._new_stdin
                     stdout = sys.stdout
-                fd = stdin.fileno()
+                stdin_fd = stdin.fileno()
+                stdout_fd = stdout.fileno()
             except (ValueError, AttributeError):
                 # ValueError: someone is using a closed file descriptor as stdin
                 # AttributeError: someone is using a null file descriptor as stdin on windoez
                 stdin = None
 
-            if fd is not None:
-                if isatty(fd):
-
+            if stdin_fd is not None:
+                if isatty(stdin_fd):
                     # grab actual Ctrl+C sequence
                     try:
-                        intr = termios.tcgetattr(fd)[6][termios.VINTR]
+                        intr = termios.tcgetattr(stdin_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]
+                        backspace = termios.tcgetattr(stdin_fd)[6][termios.VERASE]
                     except Exception:
                         backspace = [b'\x7f', b'\x08']
 
-                    old_settings = termios.tcgetattr(fd)
-                    tty.setraw(fd)
-                    tty.setraw(stdout.fileno())
+                    old_settings = termios.tcgetattr(stdin_fd)
+                    tty.setraw(stdin_fd)
+
+                    # Only set stdout to raw mode if it is a TTY. This is needed when redirecting
+                    # stdout to a file since a file cannot be set to raw mode.
+                    if isatty(stdout_fd):
+                        tty.setraw(stdout_fd)
 
                     # Only echo input if no timeout is specified
                     if not seconds and echo:
-                        new_settings = termios.tcgetattr(fd)
+                        new_settings = termios.tcgetattr(stdin_fd)
                         new_settings[3] = new_settings[3] | termios.ECHO
-                        termios.tcsetattr(fd, termios.TCSANOW, new_settings)
+                        termios.tcsetattr(stdin_fd, termios.TCSANOW, new_settings)
 
                     # flush the buffer to make sure no previous key presses
                     # are read in below
                     termios.tcflush(stdin, termios.TCIFLUSH)
 
             while True:
+
                 try:
-                    if fd is not None:
+                    if stdin_fd is not None:
+
                         key_pressed = stdin.read(1)
+
                         if key_pressed == intr:  # value for Ctrl+C
                             clear_line(stdout)
                             raise KeyboardInterrupt
 
                     if not seconds:
-                        if fd is None or not isatty(fd):
+                        if stdin_fd is None or not isatty(stdin_fd):
                             display.warning("Not waiting for response to prompt as stdin is not interactive")
                             break
 
@@ -255,9 +263,9 @@ def run(self, tmp=None, task_vars=None):
             pass
         finally:
             # cleanup and save some information
-            # restore the old settings for the duped stdin fd
-            if not(None in (fd, old_settings)) and isatty(fd):
-                termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
+            # restore the old settings for the duped stdin stdin_fd
+            if not(None in (stdin_fd, old_settings)) and isatty(stdin_fd):
+                termios.tcsetattr(stdin_fd, termios.TCSADRAIN, old_settings)
 
             duration = time.time() - start
             result['stop'] = to_text(datetime.datetime.now())


Test patch:
diff --git a/test/integration/targets/pause/runme.sh b/test/integration/targets/pause/runme.sh
--- a/test/integration/targets/pause/runme.sh
+++ b/test/integration/targets/pause/runme.sh
@@ -15,6 +15,12 @@ ansible-playbook test-pause-no-tty.yml -i ../../inventory 2>&1 | \
     }
 EOF
 
+# Test redirecting stdout
+# Issue #41717
+ansible-playbook pause-3.yml -i ../../inventory > /dev/null \
+    && echo "Successfully redirected stdout" \
+    || echo "Failure when attempting to redirect stdout"
+
 # Test pause with seconds and minutes specified
 ansible-playbook test-pause.yml -i ../../inventory "$@"