diff --git a/autosubmit/platforms/paramiko_platform.py b/autosubmit/platforms/paramiko_platform.py index 8b19730a8..cf5be6d8c 100644 --- a/autosubmit/platforms/paramiko_platform.py +++ b/autosubmit/platforms/paramiko_platform.py @@ -226,6 +226,35 @@ def interactive_auth_handler(self, title, instructions, prompt_list): # pass return tuple(answers) + def map_user_config_file(self, as_conf) -> None: + """ + Maps the shared account user ssh config file to the current user config file. + Defaults to ~/.ssh/config if the mapped file does not exist. + Defaults to ~/.ssh/config_%AS_ENV_CURRENT_USER% if %AS_ENV_SSH_CONFIG_PATH% is not defined. + param as_conf: Autosubmit configuration + return: None + """ + self._user_config_file = os.path.expanduser("~/.ssh/config") + if not as_conf.is_current_real_user_owner: # Using shared account + if 'AS_ENV_SSH_CONFIG_PATH' not in self.config: # if not defined in the ENV variables, use the default + current user + mapped_config_file = os.path.expanduser(f"~/.ssh/config_{self.config['AS_ENV_CURRENT_USER']}") + else: + mapped_config_file = self.config['AS_ENV_SSH_CONFIG_PATH'] + if mapped_config_file.startswith("~"): + mapped_config_file = os.path.expanduser(mapped_config_file) + if not Path(mapped_config_file).exists(): + Log.debug(f"{mapped_config_file} not found") + else: + Log.info(f"Using {mapped_config_file} as ssh config file") + self._user_config_file = mapped_config_file + + if Path(self._user_config_file).exists(): + Log.info(f"Using {self._user_config_file} as ssh config file") + with open(self._user_config_file) as f: + self._ssh_config.parse(f) + else: + Log.warning(f"SSH config file {self._user_config_file} not found") + def connect(self, as_conf, reconnect=False): """ Creates ssh connection to host @@ -242,10 +271,7 @@ def connect(self, as_conf, reconnect=False): self._ssh = paramiko.SSHClient() self._ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) self._ssh_config = paramiko.SSHConfig() - self._user_config_file = os.path.expanduser("~/.ssh/config") - if os.path.exists(self._user_config_file): - with open(self._user_config_file) as f: - self._ssh_config.parse(f) + self.map_user_config_file(as_conf) self._host_config = self._ssh_config.lookup(self.host) if "," in self._host_config['hostname']: if reconnect: @@ -255,7 +281,7 @@ def connect(self, as_conf, reconnect=False): self._host_config['hostname'] = self._host_config['hostname'].split(',')[0] if 'identityfile' in self._host_config: self._host_config_id = self._host_config['identityfile'] - port = int(self._host_config.get('port',22)) + port = int(self._host_config.get('port', 22) ) if not self.two_factor_auth: # Agent Auth if not self.agent_auth(port): diff --git a/docs/source/userguide/index.rst b/docs/source/userguide/index.rst index f8cc26e6f..3816c097c 100644 --- a/docs/source/userguide/index.rst +++ b/docs/source/userguide/index.rst @@ -16,6 +16,7 @@ User Guide /userguide/expids /userguide/provenance /userguide/traceability + /userguide/user_mapping Command list ============ @@ -68,6 +69,3 @@ TODO add ``workflow_validation``. * :ref:`archive` * :ref:`advanced_features` - - - diff --git a/docs/source/userguide/user_mapping.rst b/docs/source/userguide/user_mapping.rst new file mode 100644 index 000000000..1952bce28 --- /dev/null +++ b/docs/source/userguide/user_mapping.rst @@ -0,0 +1,123 @@ +############ +User Mapping +############ + +About +----- + +For Autosubmit, user mapping means associating selected personal user accounts with a shared account. + +The personal user account is used to access each remote platform, while the shared account is used to run the experiments on the machine where Autosubmit is deployed. + +When to use +------------- + +When to use: When you want to run a set of shared experiments using different HPC users. + +More specifically, this can be useful for launching something like an experiment testing suite on a shared machine without having to create redundant experiments for each user who wants to run the tests. + +Prerequisites +-------------- + +* The sysadmin of the machine where Autosubmit is deployed must have created a shared user account that will be used to run the experiments. + +* The sysadmin is responsible for securing the remote keys used so that the personal user accounts are not compromised. + +* The user is responsible for keeping their personal user account details (e.g., SSH keys) secure, including not sharing them with others. + +* Someone has to create the ``platform_${SUDO_USER}.yml`` file for each user with access to the shared account. + +* Someone has to create the ``ssh_config_${SUDO_USER}`` file for each user with access to the shared account. + +How it works +-------------- + +The idea is to map two different things depending on the user logged in to the shared account to ensure the correct Autosubmit behavior. + +* Platform.yml file that contains the personal user for each platform. + +(Personal user action): The user must set the environment variable "AS_ENV_PLATFORMS_PATH" to point to the file that contains the personal platforms.yml file. + +Defaults to: None + +(One time, all shared experiments): Has to have this defined in the $autosubmit_data/$expid/conf + +.. code-block:: yaml + + ... + DEFAULT: + ... + CUSTOM_CONFIG: + ... + POST: "%AS_ENV_PLATFORMS_PATH%" + ... + ... + + +* (OPTIONAL) ssh_config file that contains the ssh config for each platform + +(Personal user action): The user must set the environment variable "AS_ENV_SSH_CONFIG_PATH" to point to a file that contains the personal ~/.ssh/config file. + +Defaults to: "~/.ssh/config" or "~/.ssh/config_${SUDO_USER}" if the env variable: "AS_ENV_PLATFORMS_PATH" is set. + + +How to activate it with examples +---------------------------------- + +* (once) Generate the platform_${SUDO_USER}.yml + +.. code-block:: yaml + + Platforms: + Platform: + User: bscXXXXX + +* (once) Generate the ssh_config_${SUDO_USER}.yml + +.. code-block:: ini + + Host marenostrum5 + Hostname glogin1.bsc.es + User bscXXXXX + Host marenostrum5.2 + Hostname glogin2.bsc.es + User bscXXXXX + +1) Set the environment variable "AS_ENV_PLATFORMS_PATH". + +.. code-block:: bash + + export AS_ENV_PLATFORMS_PATH="~/platforms/platform_${SUDO_USER}.yml" + +Tip: Add it to the shared account .bashrc file. + +2) Set the environment variable "AS_ENV_SSH_CONFIG_PATH" (OPTIONAL). + +.. code-block:: bash + + export AS_ENV_SSH_CONFIG_PATH="~/ssh/config_${SUDO_USER}.yml" + +Tip: Add it to the shared account .bashrc file. + +3) Ensure that the experiments have set the %CUSTOM_CONFIG.POST% to the "AS_ENV_PLATFORMS_PATH" variable. + +.. code-block:: bash + + cat $autosubmit_data/$expid/conf/minimal.yml + +.. code-block:: yaml + + ... + DEFAULT: + ... + CUSTOM_CONFIG: + ... + POST: "%AS_ENV_PLATFORMS_PATH%" + ... + ... + +4) Run the experiments. + +.. code-block:: bash + + autosubmit run $expid diff --git a/setup.py b/setup.py index 230582c20..807ed719a 100644 --- a/setup.py +++ b/setup.py @@ -44,7 +44,7 @@ 'py3dotplus==1.1.0', 'numpy<2', 'rocrate==0.*', - 'autosubmitconfigparser==1.0.73', + 'autosubmitconfigparser==1.0.75', 'configparser', 'setproctitle', 'invoke>=2.0', diff --git a/test/unit/test_paramiko_platform_pytest.py b/test/unit/test_paramiko_platform_pytest.py new file mode 100644 index 000000000..dd9fa0b5d --- /dev/null +++ b/test/unit/test_paramiko_platform_pytest.py @@ -0,0 +1,60 @@ +import pytest +from autosubmit.platforms.paramiko_platform import ParamikoPlatform +import os +import autosubmitconfigparser.config.configcommon + +def add_ssh_config_file(tmpdir, user, content): + if not tmpdir.join(".ssh").exists(): + tmpdir.mkdir(".ssh") + if user: + ssh_config_file = tmpdir.join(f".ssh/config_{user}") + else: + ssh_config_file = tmpdir.join(".ssh/config") + ssh_config_file.write(content) + + +@pytest.fixture(scope="function") +def generate_all_files(tmpdir): + ssh_content = """ +Host mn5-gpp + User %change% + HostName glogin1.bsc.es + ForwardAgent yes +""" + for user in [os.environ["USER"], "dummy-one"]: + ssh_content_user = ssh_content.replace("%change%", user) + add_ssh_config_file(tmpdir, user, ssh_content_user) + return tmpdir + + +@pytest.mark.parametrize("user, env_ssh_config_defined", + [(os.environ["USER"], False), + ("dummy-one", True), + ("dummy-one", False), + ("not-exists", True), + ("not_exists", False)], + ids=["OWNER", + "SUDO USER(exists) + AS_ENV_CONFIG_SSH_PATH(defined)", + "SUDO USER(exists) + AS_ENV_CONFIG_SSH_PATH(not defined)", + "SUDO USER(not exists) + AS_ENV_CONFIG_SSH_PATH(defined)", + "SUDO USER(not exists) + AS_ENV_CONFIG_SSH_PATH(not defined)"]) +def test_map_user_config_file(tmpdir, autosubmit_config, mocker, generate_all_files, user, env_ssh_config_defined): + experiment_data = { + "ROOTDIR": str(tmpdir), + "PROJDIR": str(tmpdir), + "LOCAL_TMP_DIR": str(tmpdir), + "LOCAL_ROOT_DIR": str(tmpdir), + "AS_ENV_CURRENT_USER": user, + } + if env_ssh_config_defined: + experiment_data["AS_ENV_SSH_CONFIG_PATH"] = str(tmpdir.join(f".ssh/config_{user}")) + as_conf = autosubmit_config(expid='a000', experiment_data=experiment_data) + mocker.patch('autosubmitconfigparser.config.configcommon.AutosubmitConfig.is_current_real_user_owner', os.environ["USER"] == user) + platform = ParamikoPlatform(expid='a000', name='ps', config=experiment_data) + platform._ssh_config = mocker.MagicMock() + mocker.patch('os.path.expanduser', side_effect=lambda x: x) # Easier to test, and also not mess with the real user's config + platform.map_user_config_file(as_conf) + if not env_ssh_config_defined or not tmpdir.join(f".ssh/config_{user}").exists(): + assert platform._user_config_file == "~/.ssh/config" + else: + assert platform._user_config_file == str(tmpdir.join(f".ssh/config_{user}"))