Skip to content

Commit f1764ed

Browse files
committed
chore(master): merge maint-0.9 (reanahub#590)
feat(sessions): expose user secrets in interactive sessions (reanahub#591) feat(helm): allow cluster administrator to configure ingress host (reanahub#588)
2 parents 6e9371d + 784efee commit f1764ed

File tree

8 files changed

+131
-76
lines changed

8 files changed

+131
-76
lines changed

Diff for: AUTHORS.md

+1
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
The list of contributors in alphabetical order:
44

55
- [Adelina Lintuluoto](https://orcid.org/0000-0002-0726-1452)
6+
- [Alp Tuna](https://orcid.org/0009-0001-1915-3993)
67
- [Anton Khodak](https://orcid.org/0000-0003-3263-4553)
78
- [Audrius Mecionis](https://orcid.org/0000-0002-3759-1663)
89
- [Camila Diaz](https://orcid.org/0000-0001-5543-797X)

Diff for: reana_workflow_controller/config.py

+3
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,9 @@ def _env_vars_dict_to_k8s_list(env_vars):
200200
REANA_INGRESS_CLASS_NAME = os.getenv("REANA_INGRESS_CLASS_NAME")
201201
"""REANA Ingress class name defined by the administrator to be used for interactive sessions."""
202202

203+
REANA_INGRESS_HOST = os.getenv("REANA_INGRESS_HOST", "")
204+
"""REANA Ingress host defined by the administrator."""
205+
203206
IMAGE_PULL_SECRETS = os.getenv("IMAGE_PULL_SECRETS", "").split(",")
204207
"""Docker image pull secrets which allow the usage of private images."""
205208

Diff for: reana_workflow_controller/k8s.py

+45-41
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@
1717
current_k8s_corev1_api_client,
1818
current_k8s_networking_api_client,
1919
)
20+
from reana_commons.k8s.secrets import REANAUserSecretsStore
2021
from reana_commons.k8s.volumes import (
2122
get_k8s_cvmfs_volumes,
2223
get_workspace_volume,
@@ -27,6 +28,7 @@
2728
JUPYTER_INTERACTIVE_SESSION_DEFAULT_PORT,
2829
REANA_INGRESS_ANNOTATIONS,
2930
REANA_INGRESS_CLASS_NAME,
31+
REANA_INGRESS_HOST,
3032
)
3133

3234

@@ -77,6 +79,19 @@ def __init__(
7779
name=deployment_name,
7880
labels={"reana_workflow_mode": "session"},
7981
)
82+
self._session_container = client.V1Container(
83+
name=self.deployment_name, image=self.image, env=[], volume_mounts=[]
84+
)
85+
self._pod_spec = client.V1PodSpec(
86+
containers=[self._session_container],
87+
volumes=[],
88+
node_selector=REANA_RUNTIME_SESSIONS_KUBERNETES_NODE_LABEL,
89+
# Disable service discovery with env variables, so that the environment is
90+
# not polluted with variables like `REANA_SERVER_SERVICE_HOST`
91+
enable_service_links=False,
92+
automount_service_account_token=False,
93+
)
94+
8095
self.kubernetes_objects = {
8196
"ingress": self._build_ingress(),
8297
"service": self._build_service(metadata),
@@ -104,7 +119,9 @@ def _build_ingress(self):
104119
]
105120
)
106121
spec = client.V1IngressSpec(
107-
rules=[client.V1IngressRule(http=ingress_rule_value)]
122+
rules=[
123+
client.V1IngressRule(http=ingress_rule_value, host=REANA_INGRESS_HOST)
124+
]
108125
)
109126
if REANA_INGRESS_CLASS_NAME:
110127
spec.ingress_class_name = REANA_INGRESS_CLASS_NAME
@@ -149,15 +166,6 @@ def _build_deployment(self, metadata):
149166
:param metadata: Common Kubernetes metadata for the interactive
150167
deployment.
151168
"""
152-
container = client.V1Container(name=self.deployment_name, image=self.image)
153-
pod_spec = client.V1PodSpec(
154-
containers=[container],
155-
node_selector=REANA_RUNTIME_SESSIONS_KUBERNETES_NODE_LABEL,
156-
# Disable service discovery with env variables, so that the environment is
157-
# not polluted with variables like `REANA_SERVER_SERVICE_HOST`
158-
enable_service_links=False,
159-
automount_service_account_token=False,
160-
)
161169
labels = {
162170
"app": self.deployment_name,
163171
"reana_workflow_mode": "session",
@@ -166,7 +174,7 @@ def _build_deployment(self, metadata):
166174
}
167175
template = client.V1PodTemplateSpec(
168176
metadata=client.V1ObjectMeta(labels=labels),
169-
spec=pod_spec,
177+
spec=self._pod_spec,
170178
)
171179
spec = client.V1DeploymentSpec(
172180
selector=client.V1LabelSelector(match_labels=labels),
@@ -184,36 +192,26 @@ def _build_deployment(self, metadata):
184192

185193
def add_command(self, command):
186194
"""Add a command to the deployment."""
187-
self.kubernetes_objects["deployment"].spec.template.spec.containers[
188-
0
189-
].command = command
195+
self._session_container.command = command
190196

191197
def add_command_arguments(self, args):
192198
"""Add command line arguments in addition to the command."""
193-
self.kubernetes_objects["deployment"].spec.template.spec.containers[
194-
0
195-
].args = args
199+
self._session_container.args = args
196200

197201
def add_reana_shared_storage(self):
198202
"""Add the REANA shared file system volume mount to the deployment."""
199203
volume_mount, volume = get_workspace_volume(self.workspace)
200-
self.kubernetes_objects["deployment"].spec.template.spec.containers[
201-
0
202-
].volume_mounts = [volume_mount]
203-
self.kubernetes_objects["deployment"].spec.template.spec.volumes = [volume]
204+
self._session_container.volume_mounts.append(volume_mount)
205+
self._pod_spec.volumes.append(volume)
204206

205207
def add_cvmfs_repo_mounts(self, cvmfs_repos):
206208
"""Add mounts for the provided CVMFS repositories to the deployment.
207209
208210
:param cvmfs_mounts: List of CVMFS repos to make available.
209211
"""
210212
cvmfs_volume_mounts, cvmfs_volumes = get_k8s_cvmfs_volumes(cvmfs_repos)
211-
self.kubernetes_objects["deployment"].spec.template.spec.volumes.extend(
212-
cvmfs_volumes
213-
)
214-
self.kubernetes_objects["deployment"].spec.template.spec.containers[
215-
0
216-
].volume_mounts.extend(cvmfs_volume_mounts)
213+
self._pod_spec.volumes.extend(cvmfs_volumes)
214+
self._session_container.volume_mounts.extend(cvmfs_volume_mounts)
217215

218216
def add_environment_variable(self, name, value):
219217
"""Add an environment variable.
@@ -222,24 +220,25 @@ def add_environment_variable(self, name, value):
222220
:param value: Environment variable value.
223221
"""
224222
env_var = client.V1EnvVar(name, str(value))
225-
if isinstance(
226-
self.kubernetes_objects["deployment"].spec.template.spec.containers[0].env,
227-
list,
228-
):
229-
self.kubernetes_objects["deployment"].spec.template.spec.containers[
230-
0
231-
].env.append(env_var)
232-
else:
233-
self.kubernetes_objects["deployment"].spec.template.spec.containers[
234-
0
235-
].env = [env_var]
223+
self._session_container.env.append(env_var)
236224

237225
def add_run_with_root_permissions(self):
238226
"""Run interactive session with root."""
239227
security_context = client.V1SecurityContext(run_as_user=0)
240-
self.kubernetes_objects["deployment"].spec.template.spec.containers[
241-
0
242-
].security_context = security_context
228+
self._session_container.security_context = security_context
229+
230+
def add_user_secrets(self):
231+
"""Mount the "file" secrets and set the "env" secrets in the container."""
232+
secrets_store = REANAUserSecretsStore(self.owner_id)
233+
234+
# mount file secrets
235+
secrets_volume = secrets_store.get_file_secrets_volume_as_k8s_specs()
236+
secrets_volume_mount = secrets_store.get_secrets_volume_mount_as_k8s_spec()
237+
self._pod_spec.volumes.append(secrets_volume)
238+
self._session_container.volume_mounts.append(secrets_volume_mount)
239+
240+
# set environment secrets
241+
self._session_container.env += secrets_store.get_env_secrets_as_k8s_spec()
243242

244243
def get_deployment_objects(self):
245244
"""Return the alrady built Kubernetes objects."""
@@ -255,6 +254,7 @@ def build_interactive_jupyter_deployment_k8s_objects(
255254
owner_id=None,
256255
workflow_id=None,
257256
image=None,
257+
expose_secrets=True,
258258
):
259259
"""Build the Kubernetes specification for a Jupyter NB interactive session.
260260
@@ -276,6 +276,8 @@ def build_interactive_jupyter_deployment_k8s_objects(
276276
session belongs to.
277277
:param image: Jupyter Notebook image to use, i.e.
278278
``jupyter/tensorflow-notebook`` to enable ``tensorflow``.
279+
:param expose_secrets: If true, mount the "file" secrets and set the
280+
"env" secrets in jupyter's pod.
279281
"""
280282
image = image or JUPYTER_INTERACTIVE_SESSION_DEFAULT_IMAGE
281283
cvmfs_repos = cvmfs_repos or []
@@ -297,6 +299,8 @@ def build_interactive_jupyter_deployment_k8s_objects(
297299
deployment_builder.add_reana_shared_storage()
298300
if cvmfs_repos:
299301
deployment_builder.add_cvmfs_repo_mounts(cvmfs_repos)
302+
if expose_secrets:
303+
deployment_builder.add_user_secrets()
300304
deployment_builder.add_environment_variable("NB_GID", 0)
301305
# Changes umask so all files generated by the Jupyter Notebook can be
302306
# modified by the root group users.

Diff for: reana_workflow_controller/rest/workflows_session.py

+20-31
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@
1010

1111

1212
from flask import Blueprint, jsonify, request
13+
from webargs import fields
14+
from webargs.flaskparser import use_kwargs
15+
1316
from reana_db.utils import _get_workflow_with_uuid_or_name
1417
from reana_db.models import WorkflowSession, InteractiveSessionType, RunStatus
1518

@@ -20,10 +23,14 @@
2023

2124

2225
@blueprint.route(
23-
"/workflows/<workflow_id_or_name>/open/" "<interactive_session_type>",
26+
"/workflows/<workflow_id_or_name>/open/<interactive_session_type>",
2427
methods=["POST"],
2528
)
26-
def open_interactive_session(workflow_id_or_name, interactive_session_type): # noqa
29+
@use_kwargs({"user": fields.Str(required=True)}, location="query")
30+
@use_kwargs({"image": fields.Str()}, location="json")
31+
def open_interactive_session(
32+
workflow_id_or_name, interactive_session_type, user, **kwargs
33+
): # noqa
2734
r"""Start an interactive session inside the workflow workspace.
2835
2936
---
@@ -109,45 +116,27 @@ def open_interactive_session(workflow_id_or_name, interactive_session_type): #
109116
"""
110117
try:
111118
if interactive_session_type not in InteractiveSessionType.__members__:
112-
return (
113-
jsonify(
114-
{
115-
"message": "Interactive session type {0} not found, try "
116-
"with one of: {1}".format(
117-
interactive_session_type,
118-
[e.name for e in InteractiveSessionType],
119-
)
120-
}
121-
),
122-
404,
119+
error_msg = (
120+
f"Interactive session type {interactive_session_type} not found, "
121+
f"try with one of: {[e.name for e in InteractiveSessionType]}"
123122
)
124-
interactive_session_configuration = request.json if request.is_json else {}
125-
user_uuid = request.args["user"]
126-
workflow = None
127-
workflow = _get_workflow_with_uuid_or_name(workflow_id_or_name, user_uuid)
123+
return jsonify({"message": error_msg}), 404
124+
125+
workflow = _get_workflow_with_uuid_or_name(workflow_id_or_name, user_uuid=user)
128126

129127
if workflow.sessions.first() is not None:
130-
return (
131-
jsonify({"message": "Interactive session is already open"}),
132-
404,
133-
)
128+
return jsonify({"message": "Interactive session is already open"}), 404
134129

135130
if workflow.status == RunStatus.deleted:
136-
return (
137-
jsonify(
138-
{
139-
"message": "Interactive session can't be opened from a deleted workflow"
140-
}
141-
),
142-
404,
143-
)
131+
error_msg = "Interactive session can't be opened from a deleted workflow"
132+
return jsonify({"message": error_msg}), 404
144133

145134
kwrm = KubernetesWorkflowRunManager(workflow)
146135
access_path = kwrm.start_interactive_session(
147136
interactive_session_type,
148-
image=interactive_session_configuration.get("image", None),
137+
image=kwargs.get("image"),
149138
)
150-
return jsonify({"path": "{}".format(access_path)}), 200
139+
return jsonify({"path": str(access_path)}), 200
151140

152141
except (KeyError, ValueError) as e:
153142
status_code = 400 if workflow else 404

Diff for: reana_workflow_controller/workflow_run_manager.py

+1
Original file line numberDiff line numberDiff line change
@@ -324,6 +324,7 @@ def start_interactive_session(self, interactive_session_type, **kwargs):
324324
:return: Relative path to access the interactive session.
325325
"""
326326
action_completed = True
327+
kubernetes_objects = None
327328
try:
328329
if interactive_session_type not in InteractiveSessionType.__members__:
329330
raise REANAInteractiveSessionError(

Diff for: tests/test_k8s.py

+47
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# This file is part of REANA.
2+
# Copyright (C) 2024 CERN.
3+
#
4+
# REANA is free software; you can redistribute it and/or modify it
5+
# under the terms of the MIT License; see LICENSE file for more details.
6+
7+
from unittest.mock import Mock, patch
8+
from reana_workflow_controller.k8s import InteractiveDeploymentK8sBuilder
9+
from reana_commons.k8s.secrets import REANAUserSecretsStore
10+
11+
12+
def test_interactive_deployment_k8s_builder_user_secrets(monkeypatch):
13+
"""Expose user secrets in interactive sessions"""
14+
monkeypatch.setattr(
15+
REANAUserSecretsStore,
16+
"get_file_secrets_volume_as_k8s_specs",
17+
lambda _: {"name": "secrets-volume"},
18+
)
19+
monkeypatch.setattr(
20+
REANAUserSecretsStore,
21+
"get_secrets_volume_mount_as_k8s_spec",
22+
lambda _: {"name": "secrets-volume-mount"},
23+
)
24+
monkeypatch.setattr(
25+
REANAUserSecretsStore,
26+
"get_env_secrets_as_k8s_spec",
27+
lambda _: [{"name": "third_env", "value": "3"}],
28+
)
29+
30+
builder = InteractiveDeploymentK8sBuilder(
31+
"name", "workflow_id", "owner_id", "workspace", "docker_image", "port", "path"
32+
)
33+
34+
builder.add_command_arguments(["args"])
35+
builder.add_reana_shared_storage()
36+
builder.add_user_secrets()
37+
builder.add_environment_variable("first_env", "1")
38+
builder.add_environment_variable("second_env", "2")
39+
builder.add_run_with_root_permissions()
40+
objs = builder.get_deployment_objects()
41+
42+
deployment = objs["deployment"]
43+
pod = deployment.spec.template.spec
44+
assert len(pod.containers) == 1
45+
assert {"name": "secrets-volume"} in pod.volumes
46+
assert {"name": "secrets-volume-mount"} in pod.containers[0].volume_mounts
47+
assert {"name": "third_env", "value": "3"} in pod.containers[0].env

Diff for: tests/test_views.py

+2
Original file line numberDiff line numberDiff line change
@@ -1488,6 +1488,7 @@ def test_create_interactive_session(app, default_user, sample_serial_workflow_in
14881488
current_k8s_corev1_api_client=mock.DEFAULT,
14891489
current_k8s_networking_api_client=mock.DEFAULT,
14901490
current_k8s_appsv1_api_client=mock.DEFAULT,
1491+
REANAUserSecretsStore=mock.DEFAULT,
14911492
):
14921493
res = client.post(
14931494
url_for(
@@ -1530,6 +1531,7 @@ def test_create_interactive_session_custom_image(
15301531
current_k8s_corev1_api_client=mock.DEFAULT,
15311532
current_k8s_networking_api_client=mock.DEFAULT,
15321533
current_k8s_appsv1_api_client=mock.DEFAULT,
1534+
REANAUserSecretsStore=mock.DEFAULT,
15331535
) as mocks:
15341536
client.post(
15351537
url_for(

Diff for: tests/test_workflow_run_manager.py

+12-4
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,9 @@ def test_start_interactive_session(sample_serial_workflow_in_db):
3737
) as mocks:
3838
kwrm = KubernetesWorkflowRunManager(sample_serial_workflow_in_db)
3939
if len(InteractiveSessionType):
40-
kwrm.start_interactive_session(InteractiveSessionType(0).name)
40+
kwrm.start_interactive_session(
41+
InteractiveSessionType(0).name, expose_secrets=False
42+
)
4143
mocks[
4244
"current_k8s_appsv1_api_client"
4345
].create_namespaced_deployment.assert_called_once()
@@ -66,7 +68,9 @@ def test_start_interactive_workflow_k8s_failure(sample_serial_workflow_in_db):
6668
):
6769
kwrm = KubernetesWorkflowRunManager(sample_serial_workflow_in_db)
6870
if len(InteractiveSessionType):
69-
kwrm.start_interactive_session(InteractiveSessionType(0).name)
71+
kwrm.start_interactive_session(
72+
InteractiveSessionType(0).name, expose_secrets=False
73+
)
7074

7175

7276
def test_atomic_creation_of_interactive_session(sample_serial_workflow_in_db):
@@ -92,7 +96,9 @@ def test_atomic_creation_of_interactive_session(sample_serial_workflow_in_db):
9296
try:
9397
kwrm = KubernetesWorkflowRunManager(sample_serial_workflow_in_db)
9498
if len(InteractiveSessionType):
95-
kwrm.start_interactive_session(InteractiveSessionType(0).name)
99+
kwrm.start_interactive_session(
100+
InteractiveSessionType(0).name, expose_secrets=False
101+
)
96102
except REANAInteractiveSessionError:
97103
mocks[
98104
"current_k8s_corev1_api_client"
@@ -137,7 +143,9 @@ def test_interactive_session_closure(sample_serial_workflow_in_db, session):
137143
):
138144
kwrm = KubernetesWorkflowRunManager(workflow)
139145
if len(InteractiveSessionType):
140-
kwrm.start_interactive_session(InteractiveSessionType(0).name)
146+
kwrm.start_interactive_session(
147+
InteractiveSessionType(0).name, expose_secrets=False
148+
)
141149

142150
int_session = InteractiveSession.query.filter_by(
143151
owner_id=workflow.owner_id,

0 commit comments

Comments
 (0)