Repository: StackStorm/st2

Issue:
When specifying a SSH bastion host st2actionrunner leaks sockets and throws exceptions
## SUMMARY

When specifying a bastion host for the SSH runners, st2actionrunner leaks sockets and throws exceptions when trying to close those sockets.

```
st2actionrunner-59c47ddcc8-9jjbl r1 2020-06-25 15:30:17,009 ERROR [-] Failed shutting down SSH connection to host: rndcas402
st2actionrunner-59c47ddcc8-9jjbl r1 Traceback (most recent call last):
st2actionrunner-59c47ddcc8-9jjbl r1   File "/opt/stackstorm/st2/local/lib/python2.7/site-packages/st2common/runners/parallel_ssh.py", line 218, in close
st2actionrunner-59c47ddcc8-9jjbl r1     self._hosts_client[host].close()
st2actionrunner-59c47ddcc8-9jjbl r1   File "/opt/stackstorm/st2/local/lib/python2.7/site-packages/st2common/runners/paramiko_ssh.py", line 462, in close
st2actionrunner-59c47ddcc8-9jjbl r1     self.socket.process.kill()
st2actionrunner-59c47ddcc8-9jjbl r1 AttributeError: 'Channel' object has no attribute 'process'
```

### STACKSTORM VERSION
```
3.2.0
```

### Additional Info
Looks to have been introduced here: https://github.com/StackStorm/st2/pull/4881


Discussion:

Resolving patch:
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
@@ -452,19 +452,18 @@ def run(self, cmd, timeout=None, quote=False, call_line_handler_func=False):
         return [stdout, stderr, status]
 
     def close(self):
-        self.logger.debug('Closing server connection')
-
-        self.client.close()
-
         if self.socket:
-            self.logger.debug('Closing proxycommand socket connection')
-            # https://github.com/paramiko/paramiko/issues/789  Avoid zombie ssh processes
-            self.socket.process.kill()
-            self.socket.process.poll()
+            self.socket.close()
+
+        if self.client:
+            self.client.close()
 
         if self.sftp_client:
             self.sftp_client.close()
 
+        if self.bastion_socket:
+            self.bastion_socket.close()
+
         if self.bastion_client:
             self.bastion_client.close()
 


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
@@ -814,20 +814,46 @@ def test_socket_closed(self):
 
         # Make sure .close() doesn't actually call anything real
         ssh_client.client = Mock()
-        ssh_client.sftp_client = None
-        ssh_client.bastion_client = None
+        ssh_client.sftp_client = Mock()
+        ssh_client.bastion_client = Mock()
 
         ssh_client.socket = Mock()
+        ssh_client.bastion_socket = Mock()
 
         # Make sure we havent called any close methods at this point
         # TODO: Replace these with .assert_not_called() once it's Python 3.6+ only
-        self.assertEqual(ssh_client.socket.process.kill.call_count, 0)
-        self.assertEqual(ssh_client.socket.process.poll.call_count, 0)
+        self.assertEqual(ssh_client.socket.close.call_count, 0)
+        self.assertEqual(ssh_client.client.close.call_count, 0)
+        self.assertEqual(ssh_client.sftp_client.close.call_count, 0)
+        self.assertEqual(ssh_client.bastion_socket.close.call_count, 0)
+        self.assertEqual(ssh_client.bastion_client.close.call_count, 0)
 
         # Call the function that has changed
         ssh_client.close()
 
-        # Make sure we have called kill and poll
         # TODO: Replace these with .assert_called_once() once it's Python 3.6+ only
-        self.assertEqual(ssh_client.socket.process.kill.call_count, 1)
-        self.assertEqual(ssh_client.socket.process.poll.call_count, 1)
+        self.assertEqual(ssh_client.socket.close.call_count, 1)
+        self.assertEqual(ssh_client.client.close.call_count, 1)
+        self.assertEqual(ssh_client.sftp_client.close.call_count, 1)
+        self.assertEqual(ssh_client.bastion_socket.close.call_count, 1)
+        self.assertEqual(ssh_client.bastion_client.close.call_count, 1)
+
+    @patch.object(ParamikoSSHClient, '_is_key_file_needs_passphrase',
+                  MagicMock(return_value=False))
+    def test_socket_not_closed_if_none(self):
+        conn_params = {'hostname': 'dummy.host.org',
+                       'username': 'ubuntu',
+                       'password': 'pass',
+                       'timeout': '600'}
+        ssh_client = ParamikoSSHClient(**conn_params)
+
+        # Make sure .close() doesn't actually call anything real
+        ssh_client.client = None
+        ssh_client.sftp_client = None
+        ssh_client.bastion_client = None
+
+        ssh_client.socket = None
+        ssh_client.bastion_socket = None
+
+        # Call the function, this should not throw an exception
+        ssh_client.close()