Skip to content

Commit

Permalink
Add the SshAutoTransport transport plugin (#6154)
Browse files Browse the repository at this point in the history
This transport plugin subclasses the `SshTransport` plugin in order to
remove all the configuration options. Instead, it parses the user's SSH
config file using `paramiko.SSHConfig` when the transport is
instantiated to determine the connection parameters automatically.

The advantage of this approach is that when configuring a `Computer`
using this plugin, the user is not prompted with a bunch of options.
Rather, if they can connect to the target machine using `ssh` directly,
the transport will also work. What's more, when the user updates their
SSH config, the transport automatically uses these changes the next time
it is instantiated as opposed to the `SshTransport` plugin which stores
the configuration in an `AuthInfo` in the database and is therefore
static.

The original implementation of this plugin looked into the `fabric`
library. This library builds on top of `paramiko` and aims to make
configuration SSH connections easier, just as this new plugin was aiming
to. However, after a closer look, it seems that fabric was not adding a
lot of clever code when it comes to parsing the user's SSH config. It
does implement some clever code for dealing with proxy jumps and
commands but the `SshTransport` also already implements this. Therefore,
it is not really justified to add `fabric` as a dependency but instead
we opt to use `paramiko` to parse the config ourselves.
  • Loading branch information
sphuber authored Jul 23, 2024
1 parent d73731f commit 71422eb
Show file tree
Hide file tree
Showing 5 changed files with 87 additions and 4 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -174,6 +174,7 @@ requires-python = '>=3.9'
[project.entry-points.'aiida.transports']
'core.local' = 'aiida.transports.plugins.local:LocalTransport'
'core.ssh' = 'aiida.transports.plugins.ssh:SshTransport'
'core.ssh_auto' = 'aiida.transports.plugins.ssh_auto:SshAutoTransport'

[project.entry-points.'aiida.workflows']
'core.arithmetic.add_multiply' = 'aiida.workflows.arithmetic.add_multiply:add_multiply'
Expand Down
61 changes: 61 additions & 0 deletions src/aiida/transports/plugins/ssh_auto.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
###########################################################################
# Copyright (c), The AiiDA team. All rights reserved. #
# This file is part of the AiiDA code. #
# #
# The code is hosted on GitHub at https://github.com/aiidateam/aiida-core #
# For further information on the license, see the LICENSE.txt file #
# For further information please visit http://www.aiida.net #
###########################################################################
"""Plugin for transport over SSH (and SFTP for file transfer)."""

import pathlib

import paramiko

from .ssh import SshTransport

__all__ = ('SshAutoTransport',)


class SshAutoTransport(SshTransport):
"""Support connection, command execution and data transfer to remote computers via SSH+SFTP."""

_valid_connect_params = []
_valid_auth_options = []

FILEPATH_CONFIG: pathlib.Path = pathlib.Path('~').expanduser() / '.ssh' / 'config'

def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs, key_policy='AutoAddPolicy')

config_ssh = paramiko.SSHConfig()

try:
with self.FILEPATH_CONFIG.open() as handle:
config_ssh.parse(handle)
except FileNotFoundError as exception:
raise RuntimeError(
f'Could not determine connection configuration as the `{self.FILEPATH_CONFIG}` does not exist.'
) from exception
except PermissionError as exception:
raise RuntimeError(
f'Could not determine connection configuration as the `{self.FILEPATH_CONFIG}` is not readable.'
) from exception

if self._machine not in config_ssh.get_hostnames():
self.logger.warning(
f'The host `{self._machine}` is not defined in SSH config, connection will most likely fail to open.'
)

config_host = config_ssh.lookup(self._machine)

self._connect_args = {
'port': config_host.get('port', 22),
'username': config_host.get('user'),
'key_filename': config_host.get('identityfile', []),
'timeout': config_host.get('connecttimeout', 60),
'proxy_command': config_host.get('proxycommand', None),
'proxy_jump': config_host.get('proxyjump', None),
}

self._machine = config_host['hostname']
14 changes: 14 additions & 0 deletions tests/cmdline/commands/test_computer.py
Original file line number Diff line number Diff line change
Expand Up @@ -971,3 +971,17 @@ def time_use_login_shell(authinfo, auth_params, use_login_shell, iterations) ->
result = run_cli_command(computer_test, [aiida_localhost.label], use_subprocess=False)
assert 'Success: all 6 tests succeeded' in result.output
assert 'computer is configured to use a login shell, which is slower compared to a normal shell' in result.output


def test_computer_ssh_auto(run_cli_command, aiida_computer):
"""Test setup of computer with ``core.ssh_auto`` entry point.
The configure step should only require the common shared options ``safe_interval`` and ``use_login_shell``.
"""
computer = aiida_computer(transport_type='core.ssh_auto').store()
assert not computer.is_configured

# It is important that no other options (except for `--safe-interval`) have to be specified for this transport type.
options = ['core.ssh_auto', computer.uuid, '--non-interactive', '--safe-interval', '0']
run_cli_command(computer_configure, options, use_subprocess=False)
assert computer.is_configured
3 changes: 1 addition & 2 deletions tests/engine/daemon/test_execmanager.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,6 @@
from aiida.common.folders import SandboxFolder
from aiida.engine.daemon import execmanager
from aiida.orm import CalcJobNode, FolderData, PortableCode, RemoteData, SinglefileData
from aiida.plugins import entry_point
from aiida.transports.plugins.local import LocalTransport


Expand All @@ -40,7 +39,7 @@ def file_hierarchy_simple():
}


@pytest.fixture(params=entry_point.get_entry_point_names('aiida.transports'))
@pytest.fixture(params=('core.local', 'core.ssh'))
def node_and_calc_info(aiida_localhost, aiida_computer_ssh, aiida_code_installed, request):
"""Return a ``CalcJobNode`` and associated ``CalcInfo`` instance."""

Expand Down
12 changes: 10 additions & 2 deletions tests/transports/test_all_plugins.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,14 +34,22 @@


@pytest.fixture(scope='function', params=entry_point.get_entry_point_names('aiida.transports'))
def custom_transport(request) -> Transport:
def custom_transport(request, tmp_path, monkeypatch) -> Transport:
"""Fixture that parametrizes over all the registered implementations of the ``CommonRelaxWorkChain``."""
plugin = TransportFactory(request.param)

if request.param == 'core.ssh':
kwargs = {'machine': 'localhost', 'timeout': 30, 'load_system_host_keys': True, 'key_policy': 'AutoAddPolicy'}
elif request.param == 'core.ssh_auto':
kwargs = {'machine': 'localhost'}
filepath_config = tmp_path / 'config'
monkeypatch.setattr(plugin, 'FILEPATH_CONFIG', filepath_config)
if not filepath_config.exists():
filepath_config.write_text('Host localhost')
else:
kwargs = {}

return TransportFactory(request.param)(**kwargs)
return plugin(**kwargs)


def test_is_open(custom_transport):
Expand Down

0 comments on commit 71422eb

Please sign in to comment.