Repository: StackStorm/st2

Issue:
Paramiko SSH runner does not override ssh port from ssh config 
##### SUMMARY
Paramiko SSH runner does not override ssh port from ssh config. It always use 22 port, I can override it only via runner parameters.

##### ISSUE TYPE
 - Bug Report

##### STACKSTORM VERSION
st2 3.0dev (f7a680a), on Python 2.7.12


##### OS / ENVIRONMENT / INSTALL METHOD
- Centos 7, Python 2.7
- k8s (openshift)


##### STEPS TO REPRODUCE
**Config section**
```ini
    [ssh_runner]
    remote_dir = /tmp
    use_ssh_config = True
    ssh_config_file_path = ~/.ssh/config
```
**ssh config**
```
Host *
StrictHostKeyChecking no
ServerAliveInterval 60
ServerAliveCountMax 60
UserKnownHostsFile=/dev/null
ForwardAgent yes
Port 2200
HostName %h
```

**Action** (instance is action parameter)
```yaml
chain:
  - name: disk_use
    ref: core.remote
    parameters:
      cmd: df -Ph
      hosts: "{{ instance }}"
    publish:
      result: |
        {% for host, result in disk_use.items() %}
        {{ result.stdout }}
        {% endfor %}

```

##### EXPECTED RESULTS
Will connect to server via SSH port *2200* 

##### ACTUAL RESULTS
```json
"traceback": "Traceback (most recent call last):\n  File \"/opt/stackstorm/st2/local/lib/python2.7/site-packages/st2common/runners/parallel_ssh.py\", line 261, in _connect\n    client.connect()\n  File \"/opt/stackstorm/st2/local/lib/python2.7/site-packages/st2common/runners/paramiko_ssh.py\", line 144, in connect\n    self.client = self._connect(host=self.hostname, socket=self.bastion_socket)\n  File \"/opt/stackstorm/st2/local/lib/python2.7/site-packages/st2common/runners/paramiko_ssh.py\", line 702, in _connect\n    client.connect(**conninfo)\n  File \"/opt/stackstorm/st2/local/lib/python2.7/site-packages/paramiko/client.py\", line 362, in connect\n    raise NoValidConnectionsError(errors)\nNoValidConnectionsError: [Errno None] Unable to connect to port 22 on <IP>\n", 
```


Discussion:
Thanks for reporting this.

I was able to confirm by looking at the code that it's indeed a bug. It has to do with how we set a default argument value on the ``ParamikoSSHClient`` class.

Resolving patch:
diff --git a/st2common/st2common/runners/parallel_ssh.py b/st2common/st2common/runners/parallel_ssh.py
--- a/st2common/st2common/runners/parallel_ssh.py
+++ b/st2common/st2common/runners/parallel_ssh.py
@@ -49,8 +49,6 @@ def __init__(self, hosts, user=None, password=None, pkey_file=None, pkey_materia
                                         new stderr line is received.
         :type handle_stderr_line_func: ``func``
         """
-        self._ssh_user = user
-
         self._ssh_user = user
         self._ssh_key_file = pkey_file
         self._ssh_key_material = pkey_material
diff --git a/st2common/st2common/runners/paramiko_ssh.py b/st2common/st2common/runners/paramiko_ssh.py
--- a/st2common/st2common/runners/paramiko_ssh.py
+++ b/st2common/st2common/runners/paramiko_ssh.py
@@ -630,9 +630,17 @@ def _connect(self, host, socket=None):
         if cfg.CONF.ssh_runner.use_ssh_config:
             ssh_config_file_info = self._get_ssh_config_for_host(host)
 
-        self.username = (self.username or ssh_config_file_info.get('user', None) or
-                         cfg.CONF.system_user.user)
-        self.port = self.port or ssh_config_file_info.get('port' or None) or DEFAULT_SSH_PORT
+        ssh_config_username = ssh_config_file_info.get('user', None)
+        ssh_config_port = ssh_config_file_info.get('port', None)
+
+        self.username = (self.username or ssh_config_username or cfg.CONF.system_user.user)
+
+        # If a custom non-default port is provided in the SSH config file we use that over the
+        # default port value provided via runner parameter
+        if ssh_config_port and (self.port == DEFAULT_SSH_PORT or not self.port):
+            self.port = int(ssh_config_port)
+        else:
+            self.port = self.port or DEFAULT_SSH_PORT
 
         # If both key file and key material are provided as action parameters,
         # throw an error informing user only one is required.
@@ -697,6 +705,7 @@ def _connect(self, host, socket=None):
 
         extra = {'_conninfo': conninfo}
         self.logger.debug('Connection info', extra=extra)
+
         try:
             client.connect(**conninfo)
         except SSHException as e:


Test patch:
diff --git a/st2actions/tests/unit/test_paramiko_ssh.py b/st2actions/tests/unit/test_paramiko_ssh.py
--- a/st2actions/tests/unit/test_paramiko_ssh.py
+++ b/st2actions/tests/unit/test_paramiko_ssh.py
@@ -13,6 +13,7 @@
 # limitations under the License.
 
 from __future__ import absolute_import
+
 import os
 
 import mock
@@ -22,13 +23,18 @@
 from mock import call, patch, Mock, MagicMock
 from six.moves import StringIO
 
+from st2common.constants.runners import DEFAULT_SSH_PORT
 from st2common.runners.paramiko_ssh import ParamikoSSHClient
 from st2tests.fixturesloader import get_resources_base_path
 import st2tests.config as tests_config
 tests_config.parse_args()
 
+__all__ = [
+    'ParamikoSSHClientTestCase'
+]
+
 
-class ParamikoSSHClientTests(unittest2.TestCase):
+class ParamikoSSHClientTestCase(unittest2.TestCase):
 
     @patch('paramiko.SSHClient', Mock)
     def setUp(self):
@@ -685,3 +691,114 @@ def mock_recv_stderr_ready():
         self.assertEqual(mock_handle_stdout_line_func.call_args_list[0][1]['line'], 'stdout 1\n')
         self.assertEqual(mock_handle_stdout_line_func.call_args_list[1][1]['line'], 'stdout 2\n')
         self.assertEqual(mock_handle_stdout_line_func.call_args_list[2][1]['line'], 'stdout 3\n')
+
+    @patch('paramiko.SSHClient')
+    def test_use_ssh_config_port_value_provided_in_the_config(self, mock_sshclient):
+        cfg.CONF.set_override(name='use_ssh_config', override=True, group='ssh_runner')
+
+        ssh_config_file_path = os.path.join(get_resources_base_path(), 'ssh', 'empty_config')
+        cfg.CONF.set_override(name='ssh_config_file_path', override=ssh_config_file_path,
+                              group='ssh_runner')
+
+        # 1. Default port is used (not explicitly provided)
+        mock_client = mock.Mock()
+        mock_sshclient.return_value = mock_client
+        conn_params = {'hostname': 'dummy.host.org',
+                       'username': 'ubuntu',
+                       'password': 'pass',
+                       'timeout': '600'}
+        ssh_client = ParamikoSSHClient(**conn_params)
+        ssh_client.connect()
+
+        call_kwargs = mock_client.connect.call_args[1]
+        self.assertEqual(call_kwargs['port'], 22)
+
+        mock_client = mock.Mock()
+        mock_sshclient.return_value = mock_client
+        conn_params = {'hostname': 'dummy.host.org',
+                       'username': 'ubuntu',
+                       'password': 'pass',
+                       'port': None,
+                       'timeout': '600'}
+        ssh_client = ParamikoSSHClient(**conn_params)
+        ssh_client.connect()
+
+        call_kwargs = mock_client.connect.call_args[1]
+        self.assertEqual(call_kwargs['port'], 22)
+
+        # 2. Default port is used (explicitly provided)
+        mock_client = mock.Mock()
+        mock_sshclient.return_value = mock_client
+        conn_params = {'hostname': 'dummy.host.org',
+                       'username': 'ubuntu',
+                       'password': 'pass',
+                       'port': DEFAULT_SSH_PORT,
+                       'timeout': '600'}
+        ssh_client = ParamikoSSHClient(**conn_params)
+        ssh_client.connect()
+
+        call_kwargs = mock_client.connect.call_args[1]
+        self.assertEqual(call_kwargs['port'], DEFAULT_SSH_PORT)
+        self.assertEqual(call_kwargs['port'], 22)
+
+        # 3. Custom port is used (explicitly provided)
+        mock_client = mock.Mock()
+        mock_sshclient.return_value = mock_client
+        conn_params = {'hostname': 'dummy.host.org',
+                       'username': 'ubuntu',
+                       'password': 'pass',
+                       'port': 5555,
+                       'timeout': '600'}
+        ssh_client = ParamikoSSHClient(**conn_params)
+        ssh_client.connect()
+
+        call_kwargs = mock_client.connect.call_args[1]
+        self.assertEqual(call_kwargs['port'], 5555)
+
+        # 4. Custom port is specified in the ssh config (it has precedence over default port)
+        ssh_config_file_path = os.path.join(get_resources_base_path(), 'ssh',
+                                            'ssh_config_custom_port')
+        cfg.CONF.set_override(name='ssh_config_file_path', override=ssh_config_file_path,
+                              group='ssh_runner')
+
+        mock_client = mock.Mock()
+        mock_sshclient.return_value = mock_client
+        conn_params = {'hostname': 'dummy.host.org',
+                       'username': 'ubuntu',
+                       'password': 'pass'}
+        ssh_client = ParamikoSSHClient(**conn_params)
+        ssh_client.connect()
+
+        call_kwargs = mock_client.connect.call_args[1]
+        self.assertEqual(call_kwargs['port'], 6677)
+
+        mock_client = mock.Mock()
+        mock_sshclient.return_value = mock_client
+        conn_params = {'hostname': 'dummy.host.org',
+                       'username': 'ubuntu',
+                       'password': 'pass',
+                       'port': DEFAULT_SSH_PORT}
+        ssh_client = ParamikoSSHClient(**conn_params)
+        ssh_client.connect()
+
+        call_kwargs = mock_client.connect.call_args[1]
+        self.assertEqual(call_kwargs['port'], 6677)
+
+        # 5. Custom port is specified in ssh config, but one is also provided via runner parameter
+        # (runner parameter one has precedence)
+        ssh_config_file_path = os.path.join(get_resources_base_path(), 'ssh',
+                                            'ssh_config_custom_port')
+        cfg.CONF.set_override(name='ssh_config_file_path', override=ssh_config_file_path,
+                              group='ssh_runner')
+
+        mock_client = mock.Mock()
+        mock_sshclient.return_value = mock_client
+        conn_params = {'hostname': 'dummy.host.org',
+                       'username': 'ubuntu',
+                       'password': 'pass',
+                       'port': 9999}
+        ssh_client = ParamikoSSHClient(**conn_params)
+        ssh_client.connect()
+
+        call_kwargs = mock_client.connect.call_args[1]
+        self.assertEqual(call_kwargs['port'], 9999)