Repository: ansible/ansible

Issue:
shell module breaks even when become_user has a valid login shell
Possibly related to https://github.com/ansible/ansible/issues/26741

(see "MITIGATION" below)

##### ISSUE TYPE
<!--- Pick one below and delete the rest: -->
 - Bug Report

##### COMPONENT NAME
<!--- Name of the module/plugin/task/feature -->
 - shell

##### ANSIBLE VERSION
<!--- Paste verbatim output from "ansible --version" between quotes below -->
```
ansible 2.4.0.0
  config file = /home/mozc/ansible.cfg
  configured module search path = [u'/home/mozc/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python2.7/dist-packages/ansible
  executable location = /usr/bin/ansible
  python version = 2.7.13 (default, Jan 19 2017, 14:48:08) [GCC 6.3.0 20170118]

```

##### CONFIGURATION
<!---
If using Ansible 2.4 or above, paste the results of "ansible-config dump --only-changed"

Otherwise, mention any settings you have changed/added/removed in ansible.cfg
(or using the ANSIBLE_* environment variables).

-->
```
DEFAULT_HOST_LIST(/home/mozc/ansible.cfg) = [u'/home/mozc/inventory']
HOST_KEY_CHECKING(/home/mozc/ansible.cfg) = True
```
##### OS / ENVIRONMENT
<!---
Mention the OS you are running Ansible from, and the OS you are
managing, or say "N/A" for anything that is not platform-specific.
Also mention the specific version of what you are trying to control,
e.g. if this is a network bug the version of firmware on the network device.
-->
Running Ansible on: Ubuntu Mate 17.04
Managing a target with: Ubuntu 14.04.5 LTS

##### SUMMARY
After upgrade to 2.4.0.0, playbook task with shell module fails when become=yes. 
In this case, become_user = root, and the root user's login shell is set to /bin/bash (unlike with issue #26741).

Error:

`
fatal: [www]: FAILED! => {"changed": true, "cmd": "find . -type f -maxdepth 1 -name \"*\"", "delta": "0:00:00.002409", "end": "2017-09-20 11:20:25.169690", "failed": true, "msg": "non-zero return code", "rc": 1, "start": "2017-09-20 11:20:25.167281", "stderr": "", "stderr_lines": [], "stdout": "This account is currently not available.", "stdout_lines": ["This account is currently not available."]}`

The same task succeeds with Ansible 2.3.2

##### 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.
-->
(Not sure how to reproduce reliably)
This seems to happen only on an Azure VM created with bitnami wordpress image (from Azure Marketplace). 

<!--- Paste example playbooks or commands between quotes below -->
```
- hosts: www
  become: yes
  become_user: root
  become_method: sudo
  gather_facts: no
  tasks:
  - name: Invoke shell
    shell: find /var/log -name "syslog*" 

```

<!--- You can also paste gist.github.com links for larger files -->
Output from one success (v2.3.2) and one failure (v2.4) on the same server with the 
https://gist.github.com/sastrytumuluri/44d55b4ce059b8aa6beae6d2f30c2728

##### EXPECTED RESULTS
List of syslog files

##### ACTUAL RESULTS
Play task failed with error:
`"This account is currently not available."`

<!--- Paste verbatim command output between quotes below -->
```
<www.problem.server> ESTABLISH SSH CONNECTION FOR USER: r1user
<www.problem.server> SSH: EXEC ssh -C -o ControlMaster=auto -o ControlPersist=60s -o 'IdentityFile="/home/mhx/id_awt"' -o KbdInteractiveAuthentication=no -o PreferredAuthentications=gssapi-with-mic,gssapi-keyex,hostbased,publickey -o PasswordAuthentication=no -o User=r1user -o ConnectTimeout=10 -o ControlPath=/home/mozc/.ansible/cp/167d0ebb4b -tt www.problem.server '/bin/sh -c '"'"'sudo -H -S -n -u root /bin/sh -c '"'"'"'"'"'"'"'"'echo BECOME-SUCCESS-gapawmqehfxfsgmfnswjquisiknobaql; /usr/bin/python /home/r1user/.ansible/tmp/ansible-tmp-1505907405.08-91325840725584/command.py; rm -rf "/home/r1user/.ansible/tmp/ansible-tmp-1505907405.08-91325840725584/" > /dev/null 2>&1'"'"'"'"'"'"'"'"' && sleep 0'"'"''
<www.problem.server> (0, '\r\n{"changed": true, "end": "2017-09-20 11:36:54.990036", "stdout": "This account is currently not available.", "cmd": "find /var/log -name \\"syslog*\\"", "failed": true, "delta": "0:00:00.002505", "stderr": "", "rc": 1, "invocation": {"module_args": {"warn": true, "executable": null, "_uses_shell": true, "_raw_params": "find /var/log -name \\"syslog*\\"", "removes": null, "creates": null, "chdir": null, "stdin": null}}, "start": "2017-09-20 11:36:54.987531", "msg": "non-zero return code"}\r\n', 'Shared connection to www.problem.server closed.\r\n')
fatal: [www]: FAILED! => {
    "changed": true, 
    "cmd": "find /var/log -name \"syslog*\"", 
    "delta": "0:00:00.002505", 
    "end": "2017-09-20 11:36:54.990036", 
    "failed": true, 
    "invocation": {
        "module_args": {
            "_raw_params": "find /var/log -name \"syslog*\"", 
            "_uses_shell": true, 
            "chdir": null, 
            "creates": null, 
            "executable": null, 
            "removes": null, 
            "stdin": null, 
            "warn": true
        }
    }, 
    "msg": "non-zero return code", 
    "rc": 1, 
    "start": "2017-09-20 11:36:54.987531", 
    "stderr": "", 
    "stderr_lines": [], 
    "stdout": "This account is currently not available.", 
    "stdout_lines": [
        "This account is currently not available."
    ]
}
	to retry, use: --limit @/home/mozc/chkshell.retry
```
##### MITIGATION / RELATED
It seems that the Azure Marketplace script created the VM with 3 users all of them with uid 1000. 
One of the users (the first one listed in /etc/passwd) has login shell set to /usr/sbin/nologin
```
bitnami@www:~$ grep 1000 /etc/passwd
bitnami:x:1000:1000:Ubuntu:/home/bitnami:/usr/sbin/nologin
bitnamiftp:x:1000:1000::/opt/bitnami/apps:/bin/bitnami_ftp_false
r1user:x:1000:1000:Ubuntu:/home/r1user:/bin/bash
```
**The problem does NOT occur even with Ansible 2.4 when the bitnami user is given /bin/bash as the login shell.** 

**NOTE:** 

1. We are not using the user bitnami to login (the ssh key "belongs" to r1user). 
2. We are sudo-ing to the user bitnami. become_user = root.


Discussion:

Resolving patch:
diff --git a/lib/ansible/module_utils/basic.py b/lib/ansible/module_utils/basic.py
--- a/lib/ansible/module_utils/basic.py
+++ b/lib/ansible/module_utils/basic.py
@@ -800,6 +800,7 @@ def __init__(self, argument_spec, bypass_checks=False, no_log=False,
         self._debug = False
         self._diff = False
         self._socket_path = None
+        self._shell = None
         self._verbosity = 0
         # May be used to set modifications to the environment for any
         # run_command invocation
@@ -810,7 +811,7 @@ def __init__(self, argument_spec, bypass_checks=False, no_log=False,
         self.aliases = {}
         self._legal_inputs = ['_ansible_check_mode', '_ansible_no_log', '_ansible_debug', '_ansible_diff', '_ansible_verbosity',
                               '_ansible_selinux_special_fs', '_ansible_module_name', '_ansible_version', '_ansible_syslog_facility',
-                              '_ansible_socket']
+                              '_ansible_socket', '_ansible_shell_executable']
         self._options_context = list()
 
         if add_file_common_args:
@@ -1612,6 +1613,9 @@ def _check_arguments(self, check_invalid_arguments, spec=None, param=None, legal
             elif k == '_ansible_socket':
                 self._socket_path = v
 
+            elif k == '_ansible_shell_executable' and v:
+                self._shell = v
+
             elif check_invalid_arguments and k not in legal_inputs:
                 unsupported_parameters.add(k)
 
@@ -2602,14 +2606,27 @@ def run_command(self, args, check_rc=False, close_fds=True, executable=None, dat
             strings on python3, use encoding=None to turn decoding to text off.
         '''
 
-        if isinstance(args, list):
-            if use_unsafe_shell:
+        if not isinstance(args, (list, binary_type, text_type)):
+            msg = "Argument 'args' to run_command must be list or string"
+            self.fail_json(rc=257, cmd=args, msg=msg)
+
+        shell = False
+        if use_unsafe_shell:
+
+            # stringify args for unsafe/direct shell usage
+            if isinstance(args, list):
                 args = " ".join([shlex_quote(x) for x in args])
-                shell = True
-        elif isinstance(args, (binary_type, text_type)):
-            if use_unsafe_shell:
-                shell = True
+
+            # not set explicitly, check if set by controller
+            if executable:
+                args = [executable, '-c', args]
+            elif self._shell not in (None, '/bin/sh'):
+                args = [self._shell, '-c', args]
             else:
+                shell = True
+        else:
+            # ensure args are a list
+            if isinstance(args, (binary_type, text_type)):
                 # On python2.6 and below, shlex has problems with text type
                 # On python3, shlex needs a text type.
                 if PY2:
@@ -2617,18 +2634,9 @@ def run_command(self, args, check_rc=False, close_fds=True, executable=None, dat
                 elif PY3:
                     args = to_text(args, errors='surrogateescape')
                 args = shlex.split(args)
-        else:
-            msg = "Argument 'args' to run_command must be list or string"
-            self.fail_json(rc=257, cmd=args, msg=msg)
 
-        shell = False
-        if use_unsafe_shell:
-            if executable is None:
-                executable = os.environ.get('SHELL')
-            if executable:
-                args = [executable, '-c', args]
-            else:
-                shell = True
+            # expand shellisms
+            args = [os.path.expanduser(os.path.expandvars(x)) for x in args if x is not None]
 
         prompt_re = None
         if prompt_regex:
@@ -2642,10 +2650,6 @@ def run_command(self, args, check_rc=False, close_fds=True, executable=None, dat
             except re.error:
                 self.fail_json(msg="invalid prompt regular expression given to run_command")
 
-        # expand things like $HOME and ~
-        if not shell:
-            args = [os.path.expanduser(os.path.expandvars(x)) for x in args if x is not None]
-
         rc = 0
         msg = None
         st_in = None
diff --git a/lib/ansible/plugins/action/__init__.py b/lib/ansible/plugins/action/__init__.py
--- a/lib/ansible/plugins/action/__init__.py
+++ b/lib/ansible/plugins/action/__init__.py
@@ -607,6 +607,9 @@ def _update_module_args(self, module_name, module_args, task_vars):
         # give the module the socket for persistent connections
         module_args['_ansible_socket'] = task_vars.get('ansible_socket')
 
+        # make sure all commands use the designated shell executable
+        module_args['_ansible_shell_executable'] = self._play_context.executable
+
     def _execute_module(self, module_name=None, module_args=None, tmp=None, task_vars=None, persist_files=False, delete_remote_tmp=True, wrap_async=False):
         '''
         Transfer and run a module along with its arguments.


Test patch: