Repository: iterative/dvc

Issue:
ssh: support using private keys with passphrases
See the discussion at [discourse](https://discuss.dvc.org/t/how-do-i-use-dvc-with-ssh-remote/279/17?u=isidentical).

Discussion:

Patch:
diff --git a/dvc/fs/ssh.py b/dvc/fs/ssh.py
index 23f15f888..1947b3d6f 100644
--- a/dvc/fs/ssh.py
+++ b/dvc/fs/ssh.py
@@ -67,6 +67,7 @@ class SSHFileSystem(CallbackMixin, FSSpecWrapper):
         )
 
         login_info["password"] = config.get("password")
+        login_info["passphrase"] = config.get("password")
 
         raw_keys = []
         if config.get("keyfile"):
diff --git a/dvc/state.py b/dvc/state.py
index 5b2f55d0a..4881a62ef 100644
--- a/dvc/state.py
+++ b/dvc/state.py
@@ -53,7 +53,6 @@ class State(StateBase):  # pylint: disable=too-many-instance-attributes
         self.tmp_dir = tmp_dir
         self.root_dir = root_dir
         self.dvcignore = dvcignore
-        self.fs = LocalFileSystem()
 
         if not tmp_dir:
             return
@@ -85,7 +84,7 @@ class State(StateBase):  # pylint: disable=too-many-instance-attributes
         assert isinstance(hash_info, HashInfo)
         assert os.path.exists(path_info)
 
-        mtime, size = get_mtime_and_size(path_info, self.fs, self.dvcignore)
+        mtime, size = get_mtime_and_size(path_info, fs, self.dvcignore)
         inode = get_inode(path_info)
 
         logger.debug(
@@ -121,7 +120,7 @@ class State(StateBase):  # pylint: disable=too-many-instance-attributes
         if not os.path.exists(path):
             return None
 
-        mtime, size = get_mtime_and_size(path, self.fs, self.dvcignore)
+        mtime, size = get_mtime_and_size(path, fs, self.dvcignore)
         inode = get_inode(path)
 
         value = self.md5s.get(inode)
@@ -143,10 +142,10 @@ class State(StateBase):  # pylint: disable=too-many-instance-attributes
 
         assert isinstance(path_info, str) or path_info.scheme == "local"
 
-        if not self.fs.exists(path_info):
+        if not fs.exists(path_info):
             return
 
-        mtime, _ = get_mtime_and_size(path_info, self.fs, self.dvcignore)
+        mtime, _ = get_mtime_and_size(path_info, fs, self.dvcignore)
         inode = get_inode(path_info)
         relative_path = relpath(path_info, self.root_dir)
 
@@ -168,11 +167,11 @@ class State(StateBase):  # pylint: disable=too-many-instance-attributes
             for relative_path in ref:
                 path = os.path.join(self.root_dir, relative_path)
 
-                if path in used or not self.fs.exists(path):
+                if path in used or not fs.exists(path):
                     continue
 
                 inode = get_inode(path)
-                mtime, _ = get_mtime_and_size(path, self.fs, self.dvcignore)
+                mtime, _ = get_mtime_and_size(path, fs, self.dvcignore)
 
                 if ref[relative_path] == (inode, mtime):
                     logger.debug("Removing '%s' as unused link.", path)


Test patch:
diff --git a/tests/unit/fs/test_ssh.py b/tests/unit/fs/test_ssh.py
index da5a544e6..a7372ddd6 100644
--- a/tests/unit/fs/test_ssh.py
+++ b/tests/unit/fs/test_ssh.py
@@ -34,10 +34,13 @@ def test_get_kwargs_from_urls():
 
 
 def test_init():
-    fs = SSHFileSystem(user="test", host="12.34.56.78", port="1234")
+    fs = SSHFileSystem(
+        user="test", host="12.34.56.78", port="1234", password="xxx"
+    )
     assert fs.fs_args["username"] == "test"
     assert fs.fs_args["host"] == "12.34.56.78"
     assert fs.fs_args["port"] == "1234"
+    assert fs.fs_args["password"] == fs.fs_args["passphrase"] == "xxx"
 
 
 mock_ssh_config = """