diff --git a/reana_workflow_controller/config.py b/reana_workflow_controller/config.py index 122606fb..d08f8656 100644 --- a/reana_workflow_controller/config.py +++ b/reana_workflow_controller/config.py @@ -154,10 +154,40 @@ def _env_vars_dict_to_k8s_list(env_vars): ) """Common to all workflow engines environment variables for debug mode.""" -JUPYTER_INTERACTIVE_SESSION_DEFAULT_IMAGE = ( - "docker.io/jupyter/scipy-notebook:notebook-6.4.5" -) -"""Default image for Jupyter based interactive session deployments.""" +REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS = json.loads( + os.getenv("REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS", "{}") +) +"""Allowed and recommended environments to be used for interactive sessions. + +This is a dictionary where keys are the type of the interactive session. +For each session type, a list of recommended Docker images are provided (`recommended`) +and whether custom images are allowed (`allow_custom`). + +Example: +{ + "jupyter": { + "recommended": [ + { + "name": "Jupyter SciPy Notebook 6.4.5", + "image": "docker.io/jupyter/scipy-notebook:notebook-6.4.5" + } + ], + "allow_custom": true + } +} +""" + +REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES = { + type_: {recommended["image"] for recommended in config["recommended"]} + for type_, config in REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS.items() +} +"""Set of recommended images for each interactive session type.""" + +REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGE = { + type_: config["recommended"][0]["image"] + for type_, config in REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS.items() +} +"""Default image for each interactive session type.""" JUPYTER_INTERACTIVE_SESSION_DEFAULT_PORT = 8888 """Default port for Jupyter based interactive session deployments.""" diff --git a/reana_workflow_controller/k8s.py b/reana_workflow_controller/k8s.py index 8ff873f5..91af12f4 100644 --- a/reana_workflow_controller/k8s.py +++ b/reana_workflow_controller/k8s.py @@ -1,5 +1,5 @@ # This file is part of REANA. -# Copyright (C) 2019, 2020, 2021, 2022 CERN. +# Copyright (C) 2019, 2020, 2021, 2022, 2024 CERN. # # REANA is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -23,7 +23,6 @@ ) from reana_workflow_controller.config import ( # isort:skip - JUPYTER_INTERACTIVE_SESSION_DEFAULT_IMAGE, JUPYTER_INTERACTIVE_SESSION_DEFAULT_PORT, REANA_INGRESS_ANNOTATIONS, ) @@ -247,11 +246,11 @@ def build_interactive_jupyter_deployment_k8s_objects( deployment_name, workspace, access_path, + image, access_token=None, cvmfs_repos=None, owner_id=None, workflow_id=None, - image=None, ): """Build the Kubernetes specification for a Jupyter NB interactive session. @@ -267,14 +266,13 @@ def build_interactive_jupyter_deployment_k8s_objects( /me Traefik won't send the request to the interactive session (``/1234/me``) but to the root path (``/me``) giving most probably a ``404``. + :param image: Jupyter Notebook image to use, i.e. + ``jupyter/tensorflow-notebook`` to enable ``tensorflow``. :param cvmfs_mounts: List of CVMFS repos to make available. :param owner_id: Owner of the interactive session. :param workflow_id: UUID of the workflow to which the interactive session belongs to. - :param image: Jupyter Notebook image to use, i.e. - ``jupyter/tensorflow-notebook`` to enable ``tensorflow``. """ - image = image or JUPYTER_INTERACTIVE_SESSION_DEFAULT_IMAGE cvmfs_repos = cvmfs_repos or [] port = JUPYTER_INTERACTIVE_SESSION_DEFAULT_PORT deployment_builder = InteractiveDeploymentK8sBuilder( diff --git a/reana_workflow_controller/rest/workflows_session.py b/reana_workflow_controller/rest/workflows_session.py index 9b2f8e3c..8529b289 100644 --- a/reana_workflow_controller/rest/workflows_session.py +++ b/reana_workflow_controller/rest/workflows_session.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2020, 2021 CERN. +# Copyright (C) 2020, 2021, 2024 CERN. # # REANA is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. diff --git a/reana_workflow_controller/workflow_run_manager.py b/reana_workflow_controller/workflow_run_manager.py index 4dd22751..e29b83fd 100644 --- a/reana_workflow_controller/workflow_run_manager.py +++ b/reana_workflow_controller/workflow_run_manager.py @@ -63,6 +63,9 @@ JOB_CONTROLLER_CONTAINER_PORT, JOB_CONTROLLER_ENV_VARS, JOB_CONTROLLER_SHUTDOWN_ENDPOINT, + REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGE, + REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS, + REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES, REANA_RUNTIME_BATCH_TERMINATION_GRACE_PERIOD, REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_LIMIT, REANA_KUBERNETES_JOBS_MEMORY_LIMIT, @@ -316,21 +319,40 @@ def start_batch_workflow_run( logging.error(msg, exc_info=True) raise e - def start_interactive_session(self, interactive_session_type, **kwargs): + def start_interactive_session(self, interactive_session_type, image=None, **kwargs): """Start an interactive workflow run. :param interactive_session_type: One of the available interactive session types. + :param image: Docker image to use for the interactive session. :return: Relative path to access the interactive session. """ + if interactive_session_type not in InteractiveSessionType.__members__: + raise REANAInteractiveSessionError( + f"Interactive type {interactive_session_type} does not exist." + ) + + if interactive_session_type not in REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS: + raise REANAInteractiveSessionError( + f"Missing environment configuration for {interactive_session_type}." + ) + + config = REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS[interactive_session_type] + recommended_images = REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES[ + interactive_session_type + ] + default_image = REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGE[ + interactive_session_type + ] + image = image or default_image + if not config["allow_custom"] and image not in recommended_images: + raise REANAInteractiveSessionError( + f"Custom container image {image} is not allowed." + ) + action_completed = True + kubernetes_objects = None try: - if interactive_session_type not in InteractiveSessionType.__members__: - raise REANAInteractiveSessionError( - "Interactive type {} does not exist.".format( - interactive_session_type - ) - ) access_path = self._generate_interactive_workflow_path() workflow_run_name = self._workflow_run_name_generator("session") kubernetes_objects = build_interactive_k8s_objects[ @@ -339,6 +361,7 @@ def start_interactive_session(self, interactive_session_type, **kwargs): workflow_run_name, self.workflow.workspace_path, access_path, + image, access_token=self.workflow.get_owner_access_token(), cvmfs_repos=self.retrieve_required_cvmfs_repos(), owner_id=self.workflow.owner_id, diff --git a/tests/conftest.py b/tests/conftest.py index 0c1fc29d..0bb14182 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022 CERN. +# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2024 CERN. # # REANA is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -25,6 +25,11 @@ ) from sqlalchemy_utils import create_database, database_exists, drop_database +from reana_workflow_controller.config import ( + REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGE, + REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS, + REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES, +) from reana_workflow_controller.factory import create_app @@ -124,3 +129,26 @@ def sample_serial_workflow_with_retention_rule(session, sample_serial_workflow_i session.query(WorkspaceRetentionAuditLog).delete() session.delete(rule) session.commit() + + +@pytest.fixture() +def interactive_session_environments(monkeypatch): + monkeypatch.setitem( + REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS, + "jupyter", + { + "recommended": [ + {"image": "docker_image_1", "name": "image name 1"}, + {"image": "docker_image_2", "name": "image name 2"}, + ], + "allow_custom": False, + }, + ) + monkeypatch.setitem( + REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGE, "jupyter", "docker_image_1" + ) + monkeypatch.setitem( + REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES, + "jupyter", + {"docker_image_1", "docker_image_2"}, + ) diff --git a/tests/test_views.py b/tests/test_views.py index 8c561060..455107c1 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022 CERN. +# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2024 CERN. # # REANA is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -1477,7 +1477,9 @@ def test_get_workspace_diff( assert "# File" in response_data["workspace_listing"] -def test_create_interactive_session(app, default_user, sample_serial_workflow_in_db): +def test_create_interactive_session( + app, default_user, sample_serial_workflow_in_db, interactive_session_environments +): """Test create interactive session.""" wrm = WorkflowRunManager(sample_serial_workflow_in_db) expected_data = {"path": wrm._generate_interactive_workflow_path()} @@ -1501,7 +1503,7 @@ def test_create_interactive_session(app, default_user, sample_serial_workflow_in def test_create_interactive_session_unknown_type( - app, default_user, sample_serial_workflow_in_db + app, default_user, sample_serial_workflow_in_db, interactive_session_environments ): """Test create interactive session for unknown interactive type.""" with app.test_client() as client: @@ -1510,7 +1512,7 @@ def test_create_interactive_session_unknown_type( url_for( "workflows_session.open_interactive_session", workflow_id_or_name=sample_serial_workflow_in_db.id_, - interactive_session_type="terminl", + interactive_session_type="terminal", ), query_string={"user": default_user.id_}, ) @@ -1518,10 +1520,10 @@ def test_create_interactive_session_unknown_type( def test_create_interactive_session_custom_image( - app, default_user, sample_serial_workflow_in_db + app, default_user, sample_serial_workflow_in_db, interactive_session_environments ): """Create an interactive session with custom image.""" - custom_image = "test/image" + custom_image = "docker_image_2" interactive_session_configuration = {"image": custom_image} with app.test_client() as client: # create workflow diff --git a/tests/test_workflow_run_manager.py b/tests/test_workflow_run_manager.py index 190a7e4f..583a4a0c 100644 --- a/tests/test_workflow_run_manager.py +++ b/tests/test_workflow_run_manager.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2019, 2020, 2021, 2022 CERN. +# Copyright (C) 2019, 2020, 2021, 2022, 2024 CERN. # # REANA is free software; you can redistribute it and/or modify it # under the terms of the MIT License; see LICENSE file for more details. @@ -23,10 +23,18 @@ Job, ) +from reana_workflow_controller.config import ( + REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS, +) from reana_workflow_controller.errors import REANAInteractiveSessionError from reana_workflow_controller.workflow_run_manager import KubernetesWorkflowRunManager +@pytest.fixture(autouse=True) +def interactive_session_environments_autouse(interactive_session_environments): + pass + + def test_start_interactive_session(sample_serial_workflow_in_db): """Test interactive workflow run deployment.""" with patch.multiple( @@ -148,6 +156,52 @@ def test_interactive_session_closure(sample_serial_workflow_in_db, session): assert not workflow.sessions.first() +def test_interactive_session_not_allowed_image(sample_serial_workflow_in_db): + """Test interactive workflow run deployment with not allowed image.""" + with patch.multiple( + "reana_workflow_controller.k8s", + current_k8s_appsv1_api_client=DEFAULT, + current_k8s_corev1_api_client=DEFAULT, + current_k8s_networking_api_client=DEFAULT, + ): + with pytest.raises( + REANAInteractiveSessionError, + match=r".*this_image_is_not_allowed.*not allow.*", + ): + kwrm = KubernetesWorkflowRunManager(sample_serial_workflow_in_db) + if len(InteractiveSessionType): + kwrm.start_interactive_session( + InteractiveSessionType(0).name, image="this_image_is_not_allowed" + ) + + +def test_interactive_session_custom_image(sample_serial_workflow_in_db, monkeypatch): + """Test interactive workflow run deployment with custom image.""" + monkeypatch.setitem( + REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS["jupyter"], "allow_custom", True + ) + with patch.multiple( + "reana_workflow_controller.k8s", + current_k8s_appsv1_api_client=DEFAULT, + current_k8s_corev1_api_client=DEFAULT, + current_k8s_networking_api_client=DEFAULT, + ) as mocks: + kwrm = KubernetesWorkflowRunManager(sample_serial_workflow_in_db) + if len(InteractiveSessionType): + kwrm.start_interactive_session( + InteractiveSessionType(0).name, image="this is my custom image" + ) + mocks[ + "current_k8s_appsv1_api_client" + ].create_namespaced_deployment.assert_called_once() + mocks[ + "current_k8s_corev1_api_client" + ].create_namespaced_service.assert_called_once() + mocks[ + "current_k8s_networking_api_client" + ].create_namespaced_ingress.assert_called_once() + + def test_create_job_spec_kerberos( sample_serial_workflow_in_db, kerberos_user_secrets,