Skip to content

Commit 3e31e05

Browse files
committed
feat(sessions): support list of allowed notebook images (reanahub#582)
Closes reanahub#569
1 parent c68720b commit 3e31e05

7 files changed

Lines changed: 255 additions & 25 deletions

File tree

reana_workflow_controller/config.py

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -154,10 +154,60 @@ def _env_vars_dict_to_k8s_list(env_vars):
154154
)
155155
"""Common to all workflow engines environment variables for debug mode."""
156156

157-
JUPYTER_INTERACTIVE_SESSION_DEFAULT_IMAGE = (
158-
"docker.io/jupyter/scipy-notebook:notebook-6.4.5"
157+
158+
def _parse_interactive_sessions_environments(env_var):
159+
config = {}
160+
for type_ in env_var:
161+
recommended = []
162+
env_recommended = env_var[type_].get("recommended") or []
163+
for recommended_item in env_recommended:
164+
image = recommended_item.get("image")
165+
if not image:
166+
continue
167+
name = recommended_item.get("name") or image
168+
recommended.append({"name": name, "image": image})
169+
170+
config[type_] = {
171+
"allow_custom": env_var[type_].get("allow_custom", False),
172+
"recommended": recommended,
173+
}
174+
return config
175+
176+
177+
REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS = _parse_interactive_sessions_environments(
178+
json.loads(os.getenv("REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS", "{}"))
159179
)
160-
"""Default image for Jupyter based interactive session deployments."""
180+
"""Allowed and recommended environments to be used for interactive sessions.
181+
182+
This is a dictionary where keys are the type of the interactive session.
183+
For each session type, a list of recommended Docker images are provided (`recommended`)
184+
and whether custom images are allowed (`allow_custom`).
185+
186+
Example:
187+
{
188+
"jupyter": {
189+
"recommended": [
190+
{
191+
"name": "Jupyter SciPy Notebook 6.4.5",
192+
"image": "docker.io/jupyter/scipy-notebook:notebook-6.4.5"
193+
}
194+
],
195+
"allow_custom": true
196+
}
197+
}
198+
"""
199+
200+
REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES = {
201+
type_: {recommended["image"] for recommended in config["recommended"]}
202+
for type_, config in REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS.items()
203+
}
204+
"""Set of recommended images for each interactive session type."""
205+
206+
REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGES = {
207+
type_: next(iter(config["recommended"]), {}).get("image")
208+
for type_, config in REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS.items()
209+
}
210+
"""Default image for each interactive session type, can be `None`."""
161211

162212
JUPYTER_INTERACTIVE_SESSION_DEFAULT_PORT = 8888
163213
"""Default port for Jupyter based interactive session deployments."""
@@ -222,3 +272,6 @@ def _env_vars_dict_to_k8s_list(env_vars):
222272
223273
The job controller needs to clean up all the running jobs before the end of the grace period.
224274
"""
275+
276+
CONTAINER_IMAGE_ALIAS_PREFIXES = ["docker.io/", "docker.io/library/", "library/"]
277+
"""Prefixes that can be removed from container image references to generate valid image aliases."""

reana_workflow_controller/k8s.py

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,6 @@
2424
)
2525

2626
from reana_workflow_controller.config import ( # isort:skip
27-
JUPYTER_INTERACTIVE_SESSION_DEFAULT_IMAGE,
2827
JUPYTER_INTERACTIVE_SESSION_DEFAULT_PORT,
2928
REANA_INGRESS_ANNOTATIONS,
3029
REANA_INGRESS_CLASS_NAME,
@@ -249,11 +248,11 @@ def build_interactive_jupyter_deployment_k8s_objects(
249248
deployment_name,
250249
workspace,
251250
access_path,
251+
image,
252252
access_token=None,
253253
cvmfs_repos=None,
254254
owner_id=None,
255255
workflow_id=None,
256-
image=None,
257256
expose_secrets=True,
258257
):
259258
"""Build the Kubernetes specification for a Jupyter NB interactive session.
@@ -270,16 +269,15 @@ def build_interactive_jupyter_deployment_k8s_objects(
270269
/me Traefik won't send the request to the interactive session
271270
(``/1234/me``) but to the root path (``/me``) giving most probably
272271
a ``404``.
272+
:param image: Jupyter Notebook image to use, i.e.
273+
``jupyter/tensorflow-notebook`` to enable ``tensorflow``.
273274
:param cvmfs_mounts: List of CVMFS repos to make available.
274275
:param owner_id: Owner of the interactive session.
275276
:param workflow_id: UUID of the workflow to which the interactive
276277
session belongs to.
277-
:param image: Jupyter Notebook image to use, i.e.
278-
``jupyter/tensorflow-notebook`` to enable ``tensorflow``.
279278
:param expose_secrets: If true, mount the "file" secrets and set the
280279
"env" secrets in jupyter's pod.
281280
"""
282-
image = image or JUPYTER_INTERACTIVE_SESSION_DEFAULT_IMAGE
283281
cvmfs_repos = cvmfs_repos or []
284282
port = JUPYTER_INTERACTIVE_SESSION_DEFAULT_PORT
285283
deployment_builder = InteractiveDeploymentK8sBuilder(

reana_workflow_controller/rest/workflows_session.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
#
33
# This file is part of REANA.
4-
# Copyright (C) 2020, 2021 CERN.
4+
# Copyright (C) 2020, 2021, 2024 CERN.
55
#
66
# REANA is free software; you can redistribute it and/or modify it
77
# under the terms of the MIT License; see LICENSE file for more details.

reana_workflow_controller/workflow_run_manager.py

Lines changed: 77 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010
import json
1111
import logging
1212
import os
13+
from typing import List, Optional
1314

1415
from flask import current_app
1516
from kubernetes import client
@@ -59,10 +60,14 @@
5960
)
6061

6162
from reana_workflow_controller.config import ( # isort:skip
63+
CONTAINER_IMAGE_ALIAS_PREFIXES,
6264
IMAGE_PULL_SECRETS,
6365
JOB_CONTROLLER_CONTAINER_PORT,
6466
JOB_CONTROLLER_ENV_VARS,
6567
JOB_CONTROLLER_SHUTDOWN_ENDPOINT,
68+
REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGES,
69+
REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS,
70+
REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES,
6671
REANA_RUNTIME_BATCH_TERMINATION_GRACE_PERIOD,
6772
REANA_KUBERNETES_JOBS_MAX_USER_MEMORY_LIMIT,
6873
REANA_KUBERNETES_JOBS_MEMORY_LIMIT,
@@ -81,6 +86,66 @@
8186
)
8287

8388

89+
def _container_image_aliases(
90+
image: str, prefixes=CONTAINER_IMAGE_ALIAS_PREFIXES
91+
) -> List[str]:
92+
"""Return possible aliases for a docker image reference.
93+
94+
Aliases are obtained by adding/removing default prefixes like "docker.io/".
95+
Some of the returned aliases might not be valid docker image references,
96+
in particular when adding default prefixes to references that are already
97+
fully qualified.
98+
99+
Example: the returned aliases for `docker.io/library/ubuntu:24.04` are:
100+
- `docker.io/library/ubuntu:24.04`
101+
- `library/ubuntu:24.04`
102+
- `ubuntu:24.04`
103+
- `library/docker.io/library/ubuntu:24.04` (not valid)
104+
"""
105+
aliases = [image]
106+
for prefix in prefixes:
107+
if image.startswith(prefix):
108+
# remove prefix
109+
aliases.append(image[len(prefix) :])
110+
else:
111+
# add prefix
112+
aliases.append(prefix + image)
113+
return aliases
114+
115+
116+
def _validate_interactive_session_image(type_: str, user_image: Optional[str]) -> str:
117+
if type_ not in REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS:
118+
raise REANAInteractiveSessionError(
119+
f"Missing environment configuration for {type_}."
120+
)
121+
122+
config = REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS[type_]
123+
# recommended_images can be empty
124+
recommended_images = REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES[type_]
125+
# default_image can be `None`
126+
default_image = REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGES[type_]
127+
image = user_image or default_image
128+
129+
if not image:
130+
raise REANAInteractiveSessionError("Container image must be specified.")
131+
132+
if not config["allow_custom"]:
133+
# check if one of the aliases is in the recommended list
134+
aliases = _container_image_aliases(image)
135+
# normally only one alias should match, unless multiple aliases of the same
136+
# image are present in the recommended list
137+
allowed_alias = next(
138+
(alias for alias in aliases if alias in recommended_images), None
139+
)
140+
if not allowed_alias:
141+
raise REANAInteractiveSessionError(
142+
f"Custom container image {image} is not allowed."
143+
)
144+
return allowed_alias
145+
else:
146+
return image
147+
148+
84149
class WorkflowRunManager:
85150
"""Interface which specifies how to manage workflow runs."""
86151

@@ -316,22 +381,26 @@ def start_batch_workflow_run(
316381
logging.error(msg, exc_info=True)
317382
raise e
318383

319-
def start_interactive_session(self, interactive_session_type, **kwargs):
384+
def start_interactive_session(self, interactive_session_type, image=None, **kwargs):
320385
"""Start an interactive workflow run.
321386
322387
:param interactive_session_type: One of the available interactive
323388
session types.
389+
:param image: Docker image to use for the interactive session.
324390
:return: Relative path to access the interactive session.
325391
"""
392+
if interactive_session_type not in InteractiveSessionType.__members__:
393+
raise REANAInteractiveSessionError(
394+
f"Interactive type {interactive_session_type} does not exist."
395+
)
396+
397+
validated_image = _validate_interactive_session_image(
398+
interactive_session_type, image
399+
)
400+
326401
action_completed = True
327402
kubernetes_objects = None
328403
try:
329-
if interactive_session_type not in InteractiveSessionType.__members__:
330-
raise REANAInteractiveSessionError(
331-
"Interactive type {} does not exist.".format(
332-
interactive_session_type
333-
)
334-
)
335404
access_path = self._generate_interactive_workflow_path()
336405
workflow_run_name = self._workflow_run_name_generator("session")
337406
kubernetes_objects = build_interactive_k8s_objects[
@@ -340,6 +409,7 @@ def start_interactive_session(self, interactive_session_type, **kwargs):
340409
workflow_run_name,
341410
self.workflow.workspace_path,
342411
access_path,
412+
validated_image,
343413
access_token=self.workflow.get_owner_access_token(),
344414
cvmfs_repos=self.retrieve_required_cvmfs_repos(),
345415
owner_id=self.workflow.owner_id,

tests/conftest.py

Lines changed: 29 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
#
33
# This file is part of REANA.
4-
# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022 CERN.
4+
# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2024 CERN.
55
#
66
# REANA is free software; you can redistribute it and/or modify it
77
# under the terms of the MIT License; see LICENSE file for more details.
@@ -25,6 +25,11 @@
2525
)
2626
from sqlalchemy_utils import create_database, database_exists, drop_database
2727

28+
from reana_workflow_controller.config import (
29+
REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGES,
30+
REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS,
31+
REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES,
32+
)
2833
from reana_workflow_controller.factory import create_app
2934

3035

@@ -124,3 +129,26 @@ def sample_serial_workflow_with_retention_rule(session, sample_serial_workflow_i
124129
session.query(WorkspaceRetentionAuditLog).delete()
125130
session.delete(rule)
126131
session.commit()
132+
133+
134+
@pytest.fixture()
135+
def interactive_session_environments(monkeypatch):
136+
monkeypatch.setitem(
137+
REANA_INTERACTIVE_SESSIONS_ENVIRONMENTS,
138+
"jupyter",
139+
{
140+
"recommended": [
141+
{"image": "docker_image_1", "name": "image name 1"},
142+
{"image": "docker_image_2", "name": "image name 2"},
143+
],
144+
"allow_custom": False,
145+
},
146+
)
147+
monkeypatch.setitem(
148+
REANA_INTERACTIVE_SESSIONS_DEFAULT_IMAGES, "jupyter", "docker_image_1"
149+
)
150+
monkeypatch.setitem(
151+
REANA_INTERACTIVE_SESSIONS_RECOMMENDED_IMAGES,
152+
"jupyter",
153+
{"docker_image_1", "docker_image_2"},
154+
)

tests/test_views.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
# -*- coding: utf-8 -*-
22
#
33
# This file is part of REANA.
4-
# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022 CERN.
4+
# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2024 CERN.
55
#
66
# REANA is free software; you can redistribute it and/or modify it
77
# under the terms of the MIT License; see LICENSE file for more details.
@@ -1477,7 +1477,9 @@ def test_get_workspace_diff(
14771477
assert "# File" in response_data["workspace_listing"]
14781478

14791479

1480-
def test_create_interactive_session(app, default_user, sample_serial_workflow_in_db):
1480+
def test_create_interactive_session(
1481+
app, default_user, sample_serial_workflow_in_db, interactive_session_environments
1482+
):
14811483
"""Test create interactive session."""
14821484
wrm = WorkflowRunManager(sample_serial_workflow_in_db)
14831485
expected_data = {"path": wrm._generate_interactive_workflow_path()}
@@ -1502,7 +1504,7 @@ def test_create_interactive_session(app, default_user, sample_serial_workflow_in
15021504

15031505

15041506
def test_create_interactive_session_unknown_type(
1505-
app, default_user, sample_serial_workflow_in_db
1507+
app, default_user, sample_serial_workflow_in_db, interactive_session_environments
15061508
):
15071509
"""Test create interactive session for unknown interactive type."""
15081510
with app.test_client() as client:
@@ -1511,18 +1513,18 @@ def test_create_interactive_session_unknown_type(
15111513
url_for(
15121514
"workflows_session.open_interactive_session",
15131515
workflow_id_or_name=sample_serial_workflow_in_db.id_,
1514-
interactive_session_type="terminl",
1516+
interactive_session_type="terminal",
15151517
),
15161518
query_string={"user": default_user.id_},
15171519
)
15181520
assert res.status_code == 404
15191521

15201522

15211523
def test_create_interactive_session_custom_image(
1522-
app, default_user, sample_serial_workflow_in_db
1524+
app, default_user, sample_serial_workflow_in_db, interactive_session_environments
15231525
):
15241526
"""Create an interactive session with custom image."""
1525-
custom_image = "test/image"
1527+
custom_image = "docker_image_2"
15261528
interactive_session_configuration = {"image": custom_image}
15271529
with app.test_client() as client:
15281530
# create workflow

0 commit comments

Comments
 (0)