Repository: opsdroid/opsdroid

Issue:
Possible to Specify ssh key for private github skill
<!-- Before you post an issue or if you are unsure about something join our gitter channel https://gitter.im/opsdroid/ and ask away! We are more than happy to help you. -->
# Description
We almost exclusively use private github repo's, in our github enterprise.  It would be ideal if I could load a key into the docker container as a secret or config, and reference it in the configuration to be used when pulling the skill.


## Steps to Reproduce
Currently, unable to pull in a private ssh repo, without rebuilding the docker image


## Expected Functionality
```
skills:
  ## AWX (https://github.com/jseiser/skill-awx)
  - name: awx
    repo: https://github.com/jseiser/skill-nothing.git
    no-cache: true
    key: /root/.config/opsroid/whatever.pem
```

## Experienced Functionality
Explain what happened instead(Please include the debug log).

## Versions
- **Opsdroid version:**
- **Python version:**
- **OS/Docker version:**

## Configuration File
Please include your version of the configuration file bellow.

```yaml
# Your code goes here.

```

## Additional Details
Any other details you wish to include such as screenshots, console messages, etc.


<!-- Love opsdroid? Please consider supporting our collective:
 +👉  https://opencollective.com/opsdroid/donate -->


Discussion:

Resolving patch:
diff --git a/opsdroid/__main__.py b/opsdroid/__main__.py
--- a/opsdroid/__main__.py
+++ b/opsdroid/__main__.py
@@ -178,7 +178,6 @@ def main():
     welcome_message(config)
 
     with OpsDroid(config=config) as opsdroid:
-        opsdroid.load()
         opsdroid.run()
 
 
diff --git a/opsdroid/core.py b/opsdroid/core.py
--- a/opsdroid/core.py
+++ b/opsdroid/core.py
@@ -135,6 +135,7 @@ async def handle_signal(self):
 
     def run(self):
         """Start the event loop."""
+        self.sync_load()
         _LOGGER.info(_("Opsdroid is now running, press ctrl+c to exit."))
         if not self.is_running():
             self._running = True
@@ -151,7 +152,11 @@ def run(self):
         else:
             _LOGGER.error(_("Oops! Opsdroid is already running."))
 
-    def load(self):
+    def sync_load(self):
+        """Run the load modules method synchronously."""
+        self.eventloop.run_until_complete(self.load())
+
+    async def load(self):
         """Load modules."""
         self.modules = self.loader.load_modules_from_config(self.config)
         _LOGGER.debug(_("Loaded %i skills"), len(self.modules["skills"]))
@@ -161,7 +166,7 @@ def load(self):
         self.train_parsers(self.modules["skills"])
         if self.modules["databases"] is not None:
             self.start_databases(self.modules["databases"])
-        self.start_connectors(self.modules["connectors"])
+        await self.start_connectors(self.modules["connectors"])
         self.cron_task = self.eventloop.create_task(parse_crontab(self))
         self.eventloop.create_task(self.web_server.start())
 
@@ -288,7 +293,7 @@ def train_parsers(self, skills):
                 asyncio.gather(*tasks, loop=self.eventloop)
             )
 
-    def start_connectors(self, connectors):
+    async def start_connectors(self, connectors):
         """Start the connectors."""
         for connector_module in connectors:
             for _, cls in connector_module["module"].__dict__.items():
@@ -302,10 +307,7 @@ def start_connectors(self, connectors):
 
         if connectors:
             for connector in self.connectors:
-                if self.eventloop.is_running():
-                    self.eventloop.create_task(connector.connect())
-                else:
-                    self.eventloop.run_until_complete(connector.connect())
+                await connector.connect()
             for connector in self.connectors:
                 task = self.eventloop.create_task(connector.listen())
                 self.connector_tasks.append(task)
diff --git a/opsdroid/loader.py b/opsdroid/loader.py
--- a/opsdroid/loader.py
+++ b/opsdroid/loader.py
@@ -177,20 +177,28 @@ def build_module_install_path(self, config):
         return os.path.join(self.modules_directory, config["type"], config["name"])
 
     @staticmethod
-    def git_clone(git_url, install_path, branch):
+    def git_clone(git_url, install_path, branch, key_path=None):
         """Clone a git repo to a location and wait for finish.
 
         Args:
             git_url: The url to the git repository
             install_path: Location where the git repository will be cloned
             branch: The branch to be cloned
+            key_path: SSH Key for git repository
 
         """
+        git_env = os.environ.copy()
+        if key_path:
+            git_env[
+                "GIT_SSH_COMMAND"
+            ] = f"ssh -i {key_path} -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no"
+
         process = subprocess.Popen(
             ["git", "clone", "-b", branch, git_url, install_path],
             shell=False,
             stdout=subprocess.PIPE,
             stderr=subprocess.PIPE,
+            env=git_env,
         )
         Loader._communicate_process(process)
 
@@ -607,11 +615,12 @@ def _install_git_module(self, config):
         else:
             git_url = DEFAULT_GIT_URL + config["type"] + "-" + config["name"] + ".git"
 
-        if any(prefix in git_url for prefix in ["http", "https", "ssh"]):
+        if any(prefix in git_url for prefix in ["http", "https", "ssh", "git@"]):
             # TODO Test if url or ssh path exists
             # TODO Handle github authentication
             _LOGGER.info(_("Cloning %s from remote repository"), config["name"])
-            self.git_clone(git_url, config["install_path"], config["branch"])
+            key_path = config.get("key_path", None)
+            self.git_clone(git_url, config["install_path"], config["branch"], key_path)
         else:
             if os.path.isdir(git_url):
                 _LOGGER.debug(_("Cloning %s from local repository"), config["name"])


Test patch:
diff --git a/tests/test_core.py b/tests/test_core.py
--- a/tests/test_core.py
+++ b/tests/test_core.py
@@ -62,7 +62,7 @@ def test_load_modules(self):
                 "connectors": [],
             }
             with self.assertRaises(SystemExit):
-                opsdroid.load()
+                opsdroid.eventloop.run_until_complete(opsdroid.load())
             self.assertTrue(opsdroid.loader.load_modules_from_config.called)
 
     def test_run(self):
@@ -84,6 +84,7 @@ def test_run_cancelled(self):
             opsdroid.eventloop.run_until_complete = mock.Mock(
                 side_effect=asyncio.CancelledError
             )
+            opsdroid.sync_load = mock.MagicMock()
 
             with mock.patch("sys.exit") as mock_sysexit:
                 opsdroid.run()
@@ -98,6 +99,7 @@ def test_run_already_running(self):
             opsdroid.eventloop.run_until_complete = mock.Mock(
                 side_effect=asyncio.CancelledError
             )
+            opsdroid.sync_load = mock.MagicMock()
 
             with mock.patch("sys.exit") as mock_sysexit:
                 opsdroid.run()
@@ -120,9 +122,9 @@ def test_load(self, mocked_parse_crontab):
             )
             opsdroid.start_databases = mock.Mock()
             opsdroid.setup_skills = mock.Mock()
-            opsdroid.start_connectors = mock.Mock()
+            opsdroid.start_connectors = amock.CoroutineMock()
 
-            opsdroid.load()
+            opsdroid.eventloop.run_until_complete(opsdroid.load())
 
             self.assertTrue(opsdroid.start_databases.called)
             self.assertTrue(opsdroid.start_connectors.called)
@@ -139,44 +141,6 @@ def test_start_databases(self):
                 opsdroid.start_databases([module])
                 self.assertEqual(1, len(opsdroid.memory.databases))
 
-    def test_start_connectors(self):
-        with OpsDroid() as opsdroid:
-            opsdroid.start_connectors([])
-
-            module = {}
-            module["config"] = {}
-            module["module"] = importlib.import_module(
-                "tests.mockmodules.connectors.connector_mocked"
-            )
-
-            try:
-                opsdroid.start_connectors([module])
-            except NotImplementedError:
-                self.fail("Connector raised NotImplementedError.")
-            self.assertEqual(len(opsdroid.connectors), 1)
-
-            with mock.patch.object(opsdroid.eventloop, "is_running", return_value=True):
-                opsdroid.start_connectors([module])
-                self.assertEqual(len(opsdroid.connectors), 2)
-
-    def test_start_connectors_not_implemented(self):
-        with OpsDroid() as opsdroid:
-            opsdroid.start_connectors([])
-
-            module = {}
-            module["config"] = {}
-            module["module"] = importlib.import_module(
-                "tests.mockmodules.connectors.connector_bare"
-            )
-
-            with self.assertRaises(NotImplementedError):
-                opsdroid.start_connectors([module])
-                self.assertEqual(1, len(opsdroid.connectors))
-
-            with self.assertRaises(NotImplementedError):
-                opsdroid.start_connectors([module, module])
-                self.assertEqual(3, len(opsdroid.connectors))
-
     def test_multiple_opsdroids(self):
         with OpsDroid() as opsdroid:
             opsdroid.__class__.critical = mock.MagicMock()
@@ -488,3 +452,41 @@ async def test_send_name(self):
             message = patched_send.call_args[0][0]
 
             assert message is input_message
+
+    async def test_start_connectors(self):
+        with OpsDroid() as opsdroid:
+            opsdroid.start_connectors([])
+
+            module = {}
+            module["config"] = {}
+            module["module"] = importlib.import_module(
+                "tests.mockmodules.connectors.connector_mocked"
+            )
+
+            try:
+                await opsdroid.start_connectors([module])
+            except NotImplementedError:
+                self.fail("Connector raised NotImplementedError.")
+            self.assertEqual(len(opsdroid.connectors), 1)
+
+            with mock.patch.object(opsdroid.eventloop, "is_running", return_value=True):
+                await opsdroid.start_connectors([module])
+                self.assertEqual(len(opsdroid.connectors), 2)
+
+    async def test_start_connectors_not_implemented(self):
+        with OpsDroid() as opsdroid:
+            opsdroid.start_connectors([])
+
+            module = {}
+            module["config"] = {}
+            module["module"] = importlib.import_module(
+                "tests.mockmodules.connectors.connector_bare"
+            )
+
+            with self.assertRaises(NotImplementedError):
+                await opsdroid.start_connectors([module])
+                self.assertEqual(1, len(opsdroid.connectors))
+
+            with self.assertRaises(NotImplementedError):
+                await opsdroid.start_connectors([module, module])
+                self.assertEqual(3, len(opsdroid.connectors))
diff --git a/tests/test_loader.py b/tests/test_loader.py
--- a/tests/test_loader.py
+++ b/tests/test_loader.py
@@ -1,4 +1,3 @@
-import sys
 import os
 import shutil
 import subprocess
@@ -65,7 +64,7 @@ def test_load_config_valid_without_db_and_parsers(self):
     def test_load_config_broken_without_connectors(self):
         opsdroid, loader = self.setup()
         with self.assertRaises(SystemExit) as cm:
-            config = loader.load_config_file(
+            _ = loader.load_config_file(
                 [os.path.abspath("tests/configs/broken_without_connectors.yaml")]
             )
         self.assertEqual(cm.exception.code, 1)
@@ -81,7 +80,7 @@ def test_load_config_broken(self):
         opsdroid, loader = self.setup()
 
         with self.assertRaises(SystemExit) as cm:
-            config = loader.load_config_file(
+            _ = loader.load_config_file(
                 [os.path.abspath("tests/configs/full_broken.yaml")]
             )
         self.assertEqual(cm.exception.code, 1)
@@ -105,7 +104,7 @@ def test_load_exploit(self):
         """
         opsdroid, loader = self.setup()
         with self.assertRaises(SystemExit):
-            config = loader.load_config_file(
+            _ = loader.load_config_file(
                 [os.path.abspath("tests/configs/include_exploit.yaml")]
             )
             self.assertLogs("_LOGGER", "critical")
@@ -182,12 +181,16 @@ def test_load_broken_config_file(self):
     def test_git_clone(self):
         with mock.patch.object(subprocess, "Popen") as mock_subproc_popen:
             opsdroid, loader = self.setup()
+            myrsa = "/path/to/my/id_rsa"
             loader.git_clone(
                 "https://github.com/rmccue/test-repository.git",
                 os.path.join(self._tmp_dir, "/test"),
                 "master",
+                myrsa,
             )
             self.assertTrue(mock_subproc_popen.called)
+            _, mock_subproc_popen_kwargs = mock_subproc_popen.call_args
+            assert myrsa in mock_subproc_popen_kwargs["env"]["GIT_SSH_COMMAND"]
 
     def test_git_pull(self):
         with mock.patch.object(subprocess, "Popen") as mock_subproc_popen:
@@ -511,7 +514,29 @@ def test_install_specific_remote_module(self):
         ) as mockclone:
             loader._install_module(config)
             mockclone.assert_called_with(
-                config["repo"], config["install_path"], config["branch"]
+                config["repo"], config["install_path"], config["branch"], None
+            )
+
+    def test_install_specific_remote_module_ssl(self):
+        opsdroid, loader = self.setup()
+        config = {
+            "name": "testmodule",
+            "install_path": os.path.join(self._tmp_dir, "test_specific_remote_module"),
+            "repo": "https://github.com/rmccue/test-repository.git",
+            "branch": "master",
+            "key_path": os.path.join(
+                self._tmp_dir, os.path.normpath("install/from/here.key")
+            ),
+        }
+        with mock.patch("opsdroid.loader._LOGGER.debug"), mock.patch.object(
+            loader, "git_clone"
+        ) as mockclone:
+            loader._install_module(config)
+            mockclone.assert_called_with(
+                config["repo"],
+                config["install_path"],
+                config["branch"],
+                config["key_path"],
             )
 
     def test_install_specific_local_git_module(self):
@@ -600,6 +625,7 @@ def test_install_default_remote_module(self):
                     + ".git",
                     config["install_path"],
                     config["branch"],
+                    None,
                 )
         shutil.rmtree(config["install_path"], onerror=del_rw)
 
diff --git a/tests/test_main.py b/tests/test_main.py
--- a/tests/test_main.py
+++ b/tests/test_main.py
@@ -151,8 +151,6 @@ def test_main(self):
         ) as mock_cl, mock.patch.object(
             opsdroid, "welcome_message"
         ) as mock_wm, mock.patch.object(
-            OpsDroid, "load"
-        ) as mock_load, mock.patch.object(
             web, "Web"
         ), mock.patch.object(
             OpsDroid, "run"
@@ -162,5 +160,4 @@ def test_main(self):
             self.assertTrue(mock_cd.called)
             self.assertTrue(mock_cl.called)
             self.assertTrue(mock_wm.called)
-            self.assertTrue(mock_load.called)
             self.assertTrue(mock_loop.called)