diff --git a/.release-please-manifest.json b/.release-please-manifest.json index ee2bfff4..1b1f6a80 100644 --- a/.release-please-manifest.json +++ b/.release-please-manifest.json @@ -1,3 +1,3 @@ { - ".": "0.9.3" + ".": "0.9.4" } diff --git a/CHANGELOG.md b/CHANGELOG.md index 5fe0babc..55278100 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,28 @@ # Changelog +## [0.9.4](https://github.com/reanahub/reana-workflow-controller/compare/0.9.3...0.9.4) (2024-11-29) + + +### Build + +* **docker:** pin setuptools 70 ([#601](https://github.com/reanahub/reana-workflow-controller/issues/601)) ([be6a388](https://github.com/reanahub/reana-workflow-controller/commit/be6a3885f4f2e84ca77c7e09a89e5f2f06185452)) +* **python:** bump shared REANA packages as of 2024-11-28 ([#620](https://github.com/reanahub/reana-workflow-controller/issues/620)) ([179fa89](https://github.com/reanahub/reana-workflow-controller/commit/179fa89ccc4a5e77fca9efa403f4ad2003b40db3)) + + +### Features + +* **config:** upgrade to Jupyter SciPy 7.2.2 notebook ([#614](https://github.com/reanahub/reana-workflow-controller/issues/614)) ([72f0c4c](https://github.com/reanahub/reana-workflow-controller/commit/72f0c4c69759c8abf1d67c735232e5b6c033d504)) +* **helm:** allow cluster administrator to configure ingress host ([#588](https://github.com/reanahub/reana-workflow-controller/issues/588)) ([a7c9c85](https://github.com/reanahub/reana-workflow-controller/commit/a7c9c851277f3ca191c073fdc6c6d5d4149a95e8)) +* **sessions:** expose user secrets in interactive sessions ([#591](https://github.com/reanahub/reana-workflow-controller/issues/591)) ([784efee](https://github.com/reanahub/reana-workflow-controller/commit/784efee4be8b4a9785d03d3d05b00f3da2b455c2)) + + +### Bug fixes + +* **config:** read secret key from env ([#615](https://github.com/reanahub/reana-workflow-controller/issues/615)) ([7df1279](https://github.com/reanahub/reana-workflow-controller/commit/7df1279f45e0981a06c3af705873c4d1d797404d)) +* **manager:** avoid privilege escalation in Kubernetes jobs ([#615](https://github.com/reanahub/reana-workflow-controller/issues/615)) ([24563e5](https://github.com/reanahub/reana-workflow-controller/commit/24563e568044e29d4399f78d8c081d144f116761)) +* **manager:** pass RabbitMQ connection details to workflow engine ([#615](https://github.com/reanahub/reana-workflow-controller/issues/615)) ([cf4ee73](https://github.com/reanahub/reana-workflow-controller/commit/cf4ee734788da33f15a80e1fc1f0b3233ea5a007)) +* **set_workflow_status:** validate endpoint arguments ([#589](https://github.com/reanahub/reana-workflow-controller/issues/589)) ([5945d7f](https://github.com/reanahub/reana-workflow-controller/commit/5945d7fca095531b3601e551c527457f9413643c)) + ## [0.9.3](https://github.com/reanahub/reana-workflow-controller/compare/0.9.2...0.9.3) (2024-03-04) diff --git a/docs/openapi.json b/docs/openapi.json index c9aacec0..d6b04c56 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -1356,19 +1356,30 @@ "type": "string" }, { - "description": "Optional. Additional input parameters and operational options for workflow execution. Possible parameters are `CACHE=on/off`, passed to disable caching of results in serial workflows, `all_runs=True/False` deletes all runs of a given workflow if status is set to deleted and `workspace=True/False` which deletes the workspace of a workflow.", + "description": "Optional. Additional parameters to customise the workflow status change.", "in": "body", "name": "parameters", "required": false, "schema": { "properties": { - "CACHE": { - "type": "string" - }, "all_runs": { + "description": "Optional. If true, delete all runs of the workflow. Only allowed when status is `deleted`.", + "type": "boolean" + }, + "input_parameters": { + "description": "Optional. Additional input parameters that override the ones from the workflow specification. Only allowed when status is `start`.", + "type": "object" + }, + "operational_options": { + "description": "Optional. Additional operational options for workflow execution. Only allowed when status is `start`.", + "type": "object" + }, + "restart": { + "description": "Optional. If true, the workflow is a restart of an earlier workflow execution. Only allowed when status is `start`.", "type": "boolean" }, "workspace": { + "description": "Optional, but must be set to true if provided. If true, delete also the workspace of the workflow. Only allowed when status is `deleted`.", "type": "boolean" } }, diff --git a/reana_workflow_controller/config.py b/reana_workflow_controller/config.py index d341c782..a4441b5c 100644 --- a/reana_workflow_controller/config.py +++ b/reana_workflow_controller/config.py @@ -12,7 +12,11 @@ import json from distutils.util import strtobool -from reana_commons.config import REANA_COMPONENT_PREFIX, SHARED_VOLUME_PATH +from reana_commons.config import ( + MQ_CONNECTION_STRING, + REANA_COMPONENT_PREFIX, + SHARED_VOLUME_PATH, +) from reana_db.models import JobStatus, RunStatus from reana_workflow_controller.version import __version__ @@ -23,6 +27,9 @@ def _env_vars_dict_to_k8s_list(env_vars): return [{"name": name, "value": str(value)} for name, value in env_vars.items()] +SECRET_KEY = os.getenv("REANA_SECRET_KEY", "CHANGE_ME") +"""Secret key used for the application user sessions.""" + SQLALCHEMY_TRACK_MODIFICATIONS = False """Track modifications flag.""" @@ -120,7 +127,8 @@ def _env_vars_dict_to_k8s_list(env_vars): """ WORKFLOW_ENGINE_COMMON_ENV_VARS = [ - {"name": "SHARED_VOLUME_PATH", "value": SHARED_VOLUME_PATH} + {"name": "SHARED_VOLUME_PATH", "value": SHARED_VOLUME_PATH}, + {"name": "RABBIT_MQ", "value": MQ_CONNECTION_STRING}, ] """Common to all workflow engines environment variables.""" @@ -217,8 +225,8 @@ def _parse_interactive_sessions_environments(env_var): "jupyter": { "recommended": [ { - "name": "Jupyter SciPy Notebook 6.4.5", - "image": "docker.io/jupyter/scipy-notebook:notebook-6.4.5" + "name": "Jupyter SciPy Notebook 7.2.2", + "image": "docker.io/jupyter/scipy-notebook:notebook-7.2.2" } ], "allow_custom": true diff --git a/reana_workflow_controller/factory.py b/reana_workflow_controller/factory.py index a193da71..db8a3721 100644 --- a/reana_workflow_controller/factory.py +++ b/reana_workflow_controller/factory.py @@ -55,7 +55,6 @@ def create_app(config_mapping=None): if config_mapping: app.config.from_mapping(config_mapping) - app.secret_key = "super secret key" # Register API routes from reana_workflow_controller.rest import ( workflows_session, diff --git a/reana_workflow_controller/k8s.py b/reana_workflow_controller/k8s.py index 5661f2c5..f7032c4f 100644 --- a/reana_workflow_controller/k8s.py +++ b/reana_workflow_controller/k8s.py @@ -224,7 +224,9 @@ def add_environment_variable(self, name, value): def add_run_with_root_permissions(self): """Run interactive session with root.""" - security_context = client.V1SecurityContext(run_as_user=0) + security_context = client.V1SecurityContext( + run_as_user=0, allow_privilege_escalation=False + ) self._session_container.security_context = security_context def add_user_secrets(self): diff --git a/reana_workflow_controller/rest/utils.py b/reana_workflow_controller/rest/utils.py index 9a7a7990..3449bf5d 100644 --- a/reana_workflow_controller/rest/utils.py +++ b/reana_workflow_controller/rest/utils.py @@ -79,9 +79,8 @@ def start_workflow(workflow, parameters): def _start_workflow_db(workflow, parameters): workflow.status = RunStatus.pending - if parameters: - workflow.input_parameters = parameters.get("input_parameters") - workflow.operational_options = parameters.get("operational_options") + workflow.input_parameters = parameters.get("input_parameters", {}) + workflow.operational_options = parameters.get("operational_options", {}) current_db_sessions.add(workflow) current_db_sessions.commit() @@ -95,15 +94,15 @@ def _start_workflow_db(workflow, parameters): verb=get_workflow_status_change_verb(workflow.status.name), status=str(workflow.status.name), ) - if "restart" in parameters.keys(): - if parameters["restart"]: - if workflow.status not in [ - RunStatus.failed, - RunStatus.finished, - RunStatus.queued, - RunStatus.pending, - ]: - raise REANAWorkflowControllerError(failure_message) + + if parameters.get("restart"): + if workflow.status not in [ + RunStatus.failed, + RunStatus.finished, + RunStatus.queued, + RunStatus.pending, + ]: + raise REANAWorkflowControllerError(failure_message) elif workflow.status not in [RunStatus.created, RunStatus.queued]: if workflow.status == RunStatus.deleted: raise REANAWorkflowStatusError(failure_message) diff --git a/reana_workflow_controller/rest/workflows_status.py b/reana_workflow_controller/rest/workflows_status.py index aa235ab2..0a853d02 100644 --- a/reana_workflow_controller/rest/workflows_status.py +++ b/reana_workflow_controller/rest/workflows_status.py @@ -11,6 +11,9 @@ import json from flask import Blueprint, jsonify, request +from webargs import fields +from webargs.flaskparser import use_kwargs + from reana_commons.config import WORKFLOW_TIME_FORMAT from reana_commons.errors import REANASecretDoesNotExist @@ -331,7 +334,28 @@ def get_workflow_status(workflow_id_or_name): # noqa @blueprint.route("/workflows//status", methods=["PUT"]) -def set_workflow_status(workflow_id_or_name): # noqa +@use_kwargs( + { + # parameters for "start" + "input_parameters": fields.Dict(), + "operational_options": fields.Dict(), + "restart": fields.Boolean(), + # parameters for "deleted" + "all_runs": fields.Boolean(), + "workspace": fields.Boolean(), + }, + location="json", +) +@use_kwargs( + { + "user": fields.Str(required=True), + "status": fields.Str(required=True), + }, + location="query", +) +def set_workflow_status( + workflow_id_or_name: str, user: str, status: str, **parameters: dict +): # noqa r"""Set workflow status. --- @@ -365,21 +389,36 @@ def set_workflow_status(workflow_id_or_name): # noqa - name: parameters in: body description: >- - Optional. Additional input parameters and operational options for - workflow execution. Possible parameters are `CACHE=on/off`, passed - to disable caching of results in serial workflows, - `all_runs=True/False` deletes all runs of a given workflow - if status is set to deleted and `workspace=True/False` which deletes - the workspace of a workflow. + Optional. Additional parameters to customise the workflow status change. required: false schema: type: object properties: - CACHE: - type: string + operational_options: + description: >- + Optional. Additional operational options for workflow execution. + Only allowed when status is `start`. + type: object + input_parameters: + description: >- + Optional. Additional input parameters that override the ones + from the workflow specification. Only allowed when status is `start`. + type: object + restart: + description: >- + Optional. If true, the workflow is a restart of an earlier workflow execution. + Only allowed when status is `start`. + type: boolean all_runs: + description: >- + Optional. If true, delete all runs of the workflow. + Only allowed when status is `deleted`. type: boolean workspace: + description: >- + Optional, but must be set to true if provided. + If true, delete also the workspace of the workflow. + Only allowed when status is `deleted`. type: boolean responses: 200: @@ -473,24 +512,11 @@ def set_workflow_status(workflow_id_or_name): # noqa """ try: - user_uuid = request.args["user"] - workflow = _get_workflow_with_uuid_or_name(workflow_id_or_name, user_uuid) - status = request.args.get("status") + workflow = _get_workflow_with_uuid_or_name(workflow_id_or_name, user) if not (status in STATUSES): - return ( - jsonify( - { - "message": "Status {0} is not one of: {1}".format( - status, ", ".join(STATUSES) - ) - } - ), - 400, - ) + error_msg = f"Status {status} is not one of: {', '.join(STATUSES)}" + return jsonify({"message": error_msg}), 400 - parameters = {} - if request.is_json: - parameters = request.json if status == START: start_workflow(workflow, parameters) return ( @@ -506,8 +532,8 @@ def set_workflow_status(workflow_id_or_name): # noqa 200, ) elif status == DELETED: - all_runs = True if request.json.get("all_runs") else False - workspace = True if request.json.get("workspace", True) else False + all_runs = parameters.get("all_runs", False) + workspace = parameters.get("workspace", True) if not workspace: return ( jsonify( diff --git a/reana_workflow_controller/version.py b/reana_workflow_controller/version.py index 7e75319e..9f589f75 100644 --- a/reana_workflow_controller/version.py +++ b/reana_workflow_controller/version.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023 CERN. +# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023, 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 ba870374..e22f55d9 100644 --- a/reana_workflow_controller/workflow_run_manager.py +++ b/reana_workflow_controller/workflow_run_manager.py @@ -655,6 +655,7 @@ def _create_job_spec( workflow_engine_container.security_context = client.V1SecurityContext( run_as_group=WORKFLOW_RUNTIME_USER_GID, run_as_user=WORKFLOW_RUNTIME_USER_UID, + allow_privilege_escalation=False, ) workflow_engine_container.volume_mounts = [workspace_mount] diff --git a/setup.py b/setup.py index 3c073414..a9cdd39e 100644 --- a/setup.py +++ b/setup.py @@ -1,7 +1,7 @@ # -*- coding: utf-8 -*- # # This file is part of REANA. -# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024 CERN. +# Copyright (C) 2017, 2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025 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. @@ -51,7 +51,7 @@ "marshmallow>2.13.0,<3.0.0", # same upper pin as reana-server "opensearch-py>=2.7.0,<2.8.0", "packaging>=18.0", - "reana-commons[kubernetes]>=0.95.0a5,<0.96.0", + "reana-commons[kubernetes]>=0.95.0a6,<0.96.0", "reana-db>=0.95.0a4,<0.96.0", "requests>=2.25.0", "sqlalchemy-utils>=0.31.0", diff --git a/tests/test_views.py b/tests/test_views.py index 18d93dcf..6e47eabd 100644 --- a/tests/test_views.py +++ b/tests/test_views.py @@ -1384,6 +1384,51 @@ def test_start_input_parameters( assert workflow.input_parameters == parameters["input_parameters"] +def test_start_no_input_parameters( + app, + session, + default_user, + user_secrets, + corev1_api_client_with_user_secrets, + sample_serial_workflow_in_db, +): + """Test start workflow with inupt parameters.""" + workflow = sample_serial_workflow_in_db + workflow_uuid = str(sample_serial_workflow_in_db.id_) + + with app.test_client() as client: + # create workflow + workflow.status = RunStatus.created + session.add(workflow) + session.commit() + + payload = START + parameters = {"operational_options": {}} + with mock.patch( + "reana_workflow_controller.workflow_run_manager." + "current_k8s_batchv1_api_client" + ): + # provide user secret store + with mock.patch( + "reana_commons.k8s.secrets.current_k8s_corev1_api_client", + corev1_api_client_with_user_secrets(user_secrets), + ): + # set workflow status to START and pass parameters + res = client.put( + url_for( + "statuses.set_workflow_status", + workflow_id_or_name=workflow_uuid, + ), + query_string={"user": default_user.id_, "status": "start"}, + content_type="application/json", + data=json.dumps(parameters), + ) + json_response = json.loads(res.data.decode()) + assert json_response["status"] == status_dict[payload].name + workflow = Workflow.query.filter(Workflow.id_ == workflow_uuid).first() + assert workflow.input_parameters == dict() + + def test_start_workflow_db_failure( app, session,