Repository: ansible/ansible

Issue:
Need ansible_scp_executable and ansible_sftp_executable
##### ISSUE TYPE
 - ~~Bug  Report~~
- Feature Idea

##### COMPONENT NAME
~~playbook_executor~~
connection/ssh

##### ANSIBLE VERSION
```
ansible 2.6.0 (devel 60eee696ae) last updated 2018/02/23 17:18:20 (GMT +1100)
  config file = None
  configured module search path = [u'/home/will/.ansible/plugins/modules', u'/usr/share/ansible/plugins/modules']
  ansible python module location = /home/will/src/ansible-work/ansible/lib/ansible
  executable location = /usr/local/bin/ansible
  python version = 2.7.14 (default, Sep 23 2017, 22:06:14) [GCC 7.2.0]

```

##### CONFIGURATION
```
[defaults]
nocows = 1
remote_tmp=/tmp

timeout=20

inventory = inventory

jinja2_extensions=jinja2.ext.do
hash_behaviour = merge

[ssh_connection]
control_path=%(directory)s/%%h-%%r
pipelining=False
retries=3

```

##### OS / ENVIRONMENT
N/A

##### SUMMARY
Ansible allows you to override ansible_ssh_executable but does not allow overriding the scp and sftp executable paths. When using wrapper scripts, you probably want to override these commands to inject whatever settings you're using with ssh.

##### STEPS TO REPRODUCE
Set `ansible_sftp_executable` on a machine where sftp is not in the path (or create a symlink to a non-standard location).

Run a regular command in debug mode: `ansible -vvvvv -m command -a 'echo hello' <some machine>`

##### EXPECTED RESULTS
Ansible would use the provided location to sftp to run sftp.

##### ACTUAL RESULTS
Ansible simply tries to execute sftp from the machine path.

The problem is you can override the path to the `ssh` binary on a machine but not the path to support tools - which is a problem because when you want to do this you _probably_ want the support tools overridden as well - particularly when using wrapper scripts.




Discussion:
Files identified in the description:
* [lib/ansible/executor/playbook_executor.py](https://github.com/ansible/ansible/blob/devel/lib/ansible/executor/playbook_executor.py)

If these files are inaccurate, please update the `component name` section of the description or use the `!component` bot command.

[click here for bot help](https://github.com/ansible/ansibullbot/blob/master/ISSUE_HELP.md)
<!--- boilerplate: components_banner --->

Resolving patch:
diff --git a/lib/ansible/plugins/connection/ssh.py b/lib/ansible/plugins/connection/ssh.py
--- a/lib/ansible/plugins/connection/ssh.py
+++ b/lib/ansible/plugins/connection/ssh.py
@@ -65,9 +65,24 @@
           env: [{name: ANSIBLE_SSH_EXECUTABLE}]
           ini:
           - {key: ssh_executable, section: ssh_connection}
-          yaml: {key: ssh_connection.ssh_executable}
           #const: ANSIBLE_SSH_EXECUTABLE
           version_added: "2.2"
+      sftp_executable:
+          default: sftp
+          description:
+            - This defines the location of the sftp binary. It defaults to `sftp` which will use the first binary available in $PATH.
+          env: [{name: ANSIBLE_SFTP_EXECUTABLE}]
+          ini:
+          - {key: sftp_executable, section: ssh_connection}
+          version_added: "2.6"
+      scp_executable:
+          default: scp
+          description:
+            - This defines the location of the scp binary. It defaults to `scp` which will use the first binary available in $PATH.
+          env: [{name: ANSIBLE_SCP_EXECUTABLE}]
+          ini:
+          - {key: scp_executable, section: ssh_connection}
+          version_added: "2.6"
       scp_extra_args:
           description: Extra exclusive to the 'scp' CLI
           vars:
@@ -913,15 +928,16 @@ def _file_transport_command(self, in_path, out_path, sftp_action):
         for method in methods:
             returncode = stdout = stderr = None
             if method == 'sftp':
-                cmd = self._build_command('sftp', to_bytes(host))
+                cmd = self._build_command(self.get_option('sftp_executable'), to_bytes(host))
                 in_data = u"{0} {1} {2}\n".format(sftp_action, shlex_quote(in_path), shlex_quote(out_path))
                 in_data = to_bytes(in_data, nonstring='passthru')
                 (returncode, stdout, stderr) = self._bare_run(cmd, in_data, checkrc=False)
             elif method == 'scp':
+                scp = self.get_option('scp_executable')
                 if sftp_action == 'get':
-                    cmd = self._build_command('scp', u'{0}:{1}'.format(host, shlex_quote(in_path)), out_path)
+                    cmd = self._build_command(scp, u'{0}:{1}'.format(host, shlex_quote(in_path)), out_path)
                 else:
-                    cmd = self._build_command('scp', in_path, u'{0}:{1}'.format(host, shlex_quote(out_path)))
+                    cmd = self._build_command(scp, in_path, u'{0}:{1}'.format(host, shlex_quote(out_path)))
                 in_data = None
                 (returncode, stdout, stderr) = self._bare_run(cmd, in_data, checkrc=False)
             elif method == 'piped':


Test patch:
diff --git a/test/units/plugins/connection/test_ssh.py b/test/units/plugins/connection/test_ssh.py
--- a/test/units/plugins/connection/test_ssh.py
+++ b/test/units/plugins/connection/test_ssh.py
@@ -33,6 +33,7 @@
 from ansible.module_utils._text import to_bytes
 from ansible.playbook.play_context import PlayContext
 from ansible.plugins.connection import ssh
+from ansible.plugins.loader import connection_loader
 
 
 class TestConnectionBaseClass(unittest.TestCase):
@@ -68,13 +69,13 @@ def test_plugins_connection_ssh_basic(self):
     def test_plugins_connection_ssh__build_command(self):
         pc = PlayContext()
         new_stdin = StringIO()
-        conn = ssh.Connection(pc, new_stdin)
+        conn = connection_loader.get('ssh', pc, new_stdin)
         conn._build_command('ssh')
 
     def test_plugins_connection_ssh_exec_command(self):
         pc = PlayContext()
         new_stdin = StringIO()
-        conn = ssh.Connection(pc, new_stdin)
+        conn = connection_loader.get('ssh', pc, new_stdin)
 
         conn._build_command = MagicMock()
         conn._build_command.return_value = 'ssh something something'
@@ -90,7 +91,7 @@ def test_plugins_connection_ssh__examine_output(self):
         pc = PlayContext()
         new_stdin = StringIO()
 
-        conn = ssh.Connection(pc, new_stdin)
+        conn = connection_loader.get('ssh', pc, new_stdin)
 
         conn.check_password_prompt = MagicMock()
         conn.check_become_success = MagicMock()
@@ -198,7 +199,7 @@ def _check_missing_password(line):
     def test_plugins_connection_ssh_put_file(self, mock_ospe, mock_sleep):
         pc = PlayContext()
         new_stdin = StringIO()
-        conn = ssh.Connection(pc, new_stdin)
+        conn = connection_loader.get('ssh', pc, new_stdin)
         conn._build_command = MagicMock()
         conn._bare_run = MagicMock()
 
@@ -255,9 +256,10 @@ def test_plugins_connection_ssh_put_file(self, mock_ospe, mock_sleep):
     def test_plugins_connection_ssh_fetch_file(self, mock_sleep):
         pc = PlayContext()
         new_stdin = StringIO()
-        conn = ssh.Connection(pc, new_stdin)
+        conn = connection_loader.get('ssh', pc, new_stdin)
         conn._build_command = MagicMock()
         conn._bare_run = MagicMock()
+        conn._load_name = 'ssh'
 
         conn._build_command.return_value = 'some command to run'
         conn._bare_run.return_value = (0, '', '')
@@ -269,6 +271,7 @@ def test_plugins_connection_ssh_fetch_file(self, mock_sleep):
         # Test when SFTP works
         C.DEFAULT_SCP_IF_SSH = 'smart'
         expected_in_data = b' '.join((b'get', to_bytes(shlex_quote('/path/to/in/file')), to_bytes(shlex_quote('/path/to/dest/file')))) + b'\n'
+        conn.set_options({})
         conn.fetch_file('/path/to/in/file', '/path/to/dest/file')
         conn._bare_run.assert_called_with('some command to run', expected_in_data, checkrc=False)
 
@@ -327,10 +330,11 @@ def mock_run_env(request, mocker):
     pc = PlayContext()
     new_stdin = StringIO()
 
-    conn = ssh.Connection(pc, new_stdin)
+    conn = connection_loader.get('ssh', pc, new_stdin)
     conn._send_initial_data = MagicMock()
     conn._examine_output = MagicMock()
     conn._terminate_process = MagicMock()
+    conn._load_name = 'ssh'
     conn.sshpass_pipe = [MagicMock(), MagicMock()]
 
     request.cls.pc = pc
@@ -469,27 +473,6 @@ def test_password_with_become(self):
         assert self.conn._send_initial_data.call_count == 1
         assert self.conn._send_initial_data.call_args[0][1] == 'this is input data'
 
-    def test_pasword_without_data(self):
-        # simulate no data input
-        self.mock_openpty.return_value = (98, 99)
-        self.mock_popen_res.stdout.read.side_effect = [b"some data", b"", b""]
-        self.mock_popen_res.stderr.read.side_effect = [b""]
-        self.mock_selector.select.side_effect = [
-            [(SelectorKey(self.mock_popen_res.stdout, 1001, [EVENT_READ], None), EVENT_READ)],
-            [(SelectorKey(self.mock_popen_res.stdout, 1001, [EVENT_READ], None), EVENT_READ)],
-            [(SelectorKey(self.mock_popen_res.stderr, 1002, [EVENT_READ], None), EVENT_READ)],
-            [(SelectorKey(self.mock_popen_res.stdout, 1001, [EVENT_READ], None), EVENT_READ)],
-            []]
-        self.mock_selector.get_map.side_effect = lambda: True
-
-        return_code, b_stdout, b_stderr = self.conn._run("ssh", "")
-        assert return_code == 0
-        assert b_stdout == b'some data'
-        assert b_stderr == b''
-        assert self.mock_selector.register.called is True
-        assert self.mock_selector.register.call_count == 2
-        assert self.conn._send_initial_data.called is False
-
     def test_pasword_without_data(self):
         # simulate no data input but Popen using new pty's fails
         self.mock_popen.return_value = None