Skip to content

Commit

Permalink
feat(sessions): add allowlist for interactive session images (reanahu…
Browse files Browse the repository at this point in the history
  • Loading branch information
mdonadoni committed Apr 17, 2024
1 parent 1d027ff commit b241587
Show file tree
Hide file tree
Showing 7 changed files with 161 additions and 26 deletions.
38 changes: 34 additions & 4 deletions reana_workflow_controller/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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."""
Expand Down
10 changes: 4 additions & 6 deletions reana_workflow_controller/k8s.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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,
)
Expand Down Expand Up @@ -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.
Expand All @@ -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(
Expand Down
2 changes: 1 addition & 1 deletion reana_workflow_controller/rest/workflows_session.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
37 changes: 30 additions & 7 deletions reana_workflow_controller/workflow_run_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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[
Expand All @@ -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,
Expand Down
30 changes: 29 additions & 1 deletion tests/conftest.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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


Expand Down Expand Up @@ -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"},
)
14 changes: 8 additions & 6 deletions tests/test_views.py
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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()}
Expand All @@ -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:
Expand All @@ -1510,18 +1512,18 @@ 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_},
)
assert res.status_code == 404


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
Expand Down
56 changes: 55 additions & 1 deletion tests/test_workflow_run_manager.py
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -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(
Expand Down Expand Up @@ -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,
Expand Down

0 comments on commit b241587

Please sign in to comment.