diff --git a/AUTHORS.md b/AUTHORS.md index d728b4bd..c8a30603 100644 --- a/AUTHORS.md +++ b/AUTHORS.md @@ -7,6 +7,7 @@ The list of contributors in alphabetical order: - [Audrius Mecionis](https://orcid.org/0000-0002-3759-1663) - [Camila Diaz](https://orcid.org/0000-0001-5543-797X) - [Clemens Lange](https://orcid.org/0000-0002-3632-3157) +- [Daan Rosendal](https://orcid.org/0000-0002-3447-9000) - [Daniel Prelipcean](https://orcid.org/0000-0002-4855-194X) - [Diego Rodriguez](https://orcid.org/0000-0003-0649-2002) - [Dinos Kousidis](https://orcid.org/0000-0002-4914-4289) diff --git a/docs/cmd_list.txt b/docs/cmd_list.txt index 9f9b7f74..94bd326b 100644 --- a/docs/cmd_list.txt +++ b/docs/cmd_list.txt @@ -30,6 +30,9 @@ Workflow execution commands: stop Stop a running workflow. validate Validate workflow specification file. +Workflow sharing commands: + share-add Share a workflow with other users (read-only). + Workspace interactive commands: close Close an interactive session. open Open an interactive session inside the workspace. diff --git a/reana_client/api/client.py b/reana_client/api/client.py index ecdfd5e4..33f87c2e 100644 --- a/reana_client/api/client.py +++ b/reana_client/api/client.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, 2023 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. @@ -17,20 +17,19 @@ import requests from bravado.exception import HTTPError -from reana_commons.validation.utils import validate_reana_yaml, validate_workflow_name -from reana_commons.specification import ( - load_workflow_spec_from_reana_yaml, - load_input_parameters, -) +from reana_client.config import ERROR_MESSAGES +from reana_client.errors import FileDeletionError, FileUploadError +from reana_client.utils import is_regular_path, is_uuid_v4 from reana_commons.api_client import get_current_api_client from reana_commons.config import REANA_WORKFLOW_ENGINES from reana_commons.errors import REANASecretAlreadyExists, REANASecretDoesNotExist +from reana_commons.specification import ( + load_input_parameters, + load_workflow_spec_from_reana_yaml, +) +from reana_commons.validation.utils import validate_reana_yaml, validate_workflow_name from werkzeug.local import LocalProxy -from reana_client.config import ERROR_MESSAGES -from reana_client.errors import FileDeletionError, FileUploadError -from reana_client.utils import is_uuid_v4, is_regular_path - current_rs_api_client = LocalProxy( partial(get_current_api_client, component="reana-server") ) @@ -1280,3 +1279,51 @@ def prune_workspace(workflow, include_inputs, include_outputs, access_token): ) ) raise Exception(e.response.json()["message"]) + + +def share_workflow( + workflow, user_email_to_share_with, access_token, message=None, valid_until=None +): + """Share a workflow with a user. + + :param workflow: name or id of the workflow. + :param user_email_to_share_with: user to share the workflow with. + :param access_token: access token of the current user. + :param message: Optional message to include when sharing the workflow. + :param valid_until: Specify the date when access to the workflow will expire (format: YYYY-MM-DD). + + :return: a dictionary containing the ``workflow_id``, ``workflow_name``, and + a ``message`` key with the result of the operation. + """ + try: + share_params = { + "workflow_id_or_name": workflow, + "user_email_to_share_with": user_email_to_share_with, + "access_token": access_token, + } + + if message: + share_params["message"] = message + + if valid_until: + share_params["valid_until"] = valid_until + + (response, http_response) = current_rs_api_client.api.share_workflow( + **share_params + ).result() + + if http_response.status_code == 200: + return response + else: + raise Exception( + "Expected status code 200 but replied with " + f"{http_response.status_code}" + ) + + except HTTPError as e: + logging.debug( + "Workflow could not be shared: " + f"\nStatus: {e.response.status_code}\nReason: {e.response.reason}\n" + f"Message: {e.response.json()['message']}" + ) + raise Exception(e.response.json()["message"]) diff --git a/reana_client/cli/__init__.py b/reana_client/cli/__init__.py index 508f0c7b..2b46cf0f 100644 --- a/reana_client/cli/__init__.py +++ b/reana_client/cli/__init__.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, 2023 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. @@ -11,10 +11,9 @@ import sys import click -from urllib3 import disable_warnings - -from reana_client.cli import workflow, files, ping, secrets, quotas, retention_rules +from reana_client.cli import files, ping, quotas, retention_rules, secrets, workflow from reana_client.utils import get_api_url +from urllib3 import disable_warnings DEBUG_LOG_FORMAT = ( "[%(asctime)s] p%(process)s " @@ -41,6 +40,7 @@ class ReanaCLI(click.Group): ping.configuration_group, workflow.workflow_management_group, workflow.workflow_execution_group, + workflow.workflow_sharing_group, workflow.interactive_group, files.files_group, retention_rules.retention_rules_group, diff --git a/reana_client/cli/workflow.py b/reana_client/cli/workflow.py index 12b4eb68..7a3079a8 100644 --- a/reana_client/cli/workflow.py +++ b/reana_client/cli/workflow.py @@ -15,12 +15,8 @@ import traceback import click -from jsonschema.exceptions import ValidationError -from reana_commons.config import INTERACTIVE_SESSION_TYPES, REANA_COMPUTE_BACKENDS -from reana_commons.errors import REANAValidationError -from reana_commons.validation.operational_options import validate_operational_options import yaml - +from jsonschema.exceptions import ValidationError from reana_client.cli.files import get_files, upload_files from reana_client.cli.utils import ( add_access_token_options, @@ -51,6 +47,9 @@ validate_input_parameters, validate_workflow_name_parameter, ) +from reana_commons.config import INTERACTIVE_SESSION_TYPES, REANA_COMPUTE_BACKENDS +from reana_commons.errors import REANAValidationError +from reana_commons.validation.operational_options import validate_operational_options @click.group(help="Workflow management commands") @@ -67,6 +66,13 @@ def workflow_execution_group(ctx): logging.debug(ctx.info_name) +@click.group(help="Workflow sharing commands") +@click.pass_context +def workflow_sharing_group(ctx): + """Top level wrapper for workflow sharing.""" + logging.debug(ctx.info_name) + + @workflow_management_group.command("list") @click.option( "-w", @@ -451,12 +457,12 @@ def workflow_start( \t $ reana-client start -w myanalysis.42 -p sleeptime=10 -p myparam=4\n \t $ reana-client start -w myanalysis.42 -p myparam1=myvalue1 -o CACHE=off """ - from reana_client.utils import get_api_url from reana_client.api.client import ( get_workflow_parameters, get_workflow_status, start_workflow, ) + from reana_client.utils import get_api_url def display_status(workflow: str, current_status: str): """Display the current status of the workflow.""" @@ -586,12 +592,12 @@ def workflow_restart( \t $ reana-client restart -w myanalysis.42 -o TARGET=gendata\n \t $ reana-client restart -w myanalysis.42 -o FROM=fitdata """ - from reana_client.utils import get_api_url from reana_client.api.client import ( get_workflow_parameters, get_workflow_status, start_workflow, ) + from reana_client.utils import get_api_url logging.debug("command: {}".format(ctx.command_path.replace(" ", "."))) for p in ctx.params: @@ -1372,7 +1378,7 @@ def workflow_open_interactive_session( Examples:\n \t $ reana-client open -w myanalysis.42 jupyter """ - from reana_client.api.client import open_interactive_session, info + from reana_client.api.client import info, open_interactive_session if workflow: try: @@ -1457,3 +1463,76 @@ def workflow_close_interactive_session(workflow, access_token): # noqa: D301 sys.exit(1) else: display_message("Cannot find workflow {} ".format(workflow), msg_type="error") + + +@workflow_sharing_group.command("share-add") +@check_connection +@add_workflow_option +@add_access_token_options +@click.option( + "-u", + "--user", + "users", + multiple=True, + help="Users to share the workflow with.", + required=True, +) +@click.option( + "-m", + "--message", + help="Optional message that is sent to the user(s) with the sharing invitation.", +) +@click.option( + "--valid-until", + type=click.DateTime(formats=["%Y-%m-%d"]), + help="Optional date when access to the workflow will expire for the given user(s) (format: YYYY-MM-DD).", +) +@click.pass_context +def workflow_share_add( + ctx, workflow, access_token, users, message, valid_until +): # noqa D412 + """Share a workflow with other users (read-only). + + The `share-add` command allows sharing a workflow with other users. The + users will be able to view the workflow but not modify it. + + Examples: + + \t $ reana-client share-add -w myanalysis.42 --user bob@example.org + + \t $ reana-client share-add -w myanalysis.42 --user bob@example.org --user cecile@example.org --message "Please review my analysis" --valid-until 2025-12-31 + """ + from reana_client.api.client import share_workflow + + share_errors = [] + shared_users = [] + + if valid_until: + valid_until = valid_until.strftime("%Y-%m-%d") + + for user in users: + try: + logging.info(f"Sharing workflow {workflow} with user {user}") + share_workflow( + workflow, + user, + access_token, + message=message, + valid_until=valid_until, + ) + shared_users.append(user) + except Exception as e: + share_errors.append(f"Failed to share {workflow} with {user}: {str(e)}") + logging.debug(traceback.format_exc()) + + if shared_users: + display_message( + f"{workflow} is now read-only shared with {', '.join(shared_users)}", + msg_type="success", + ) + + for error in share_errors: + display_message(error, msg_type="error") + + if share_errors: + sys.exit(1) diff --git a/reana_client/config.py b/reana_client/config.py index 78a27d6e..3be4a487 100644 --- a/reana_client/config.py +++ b/reana_client/config.py @@ -11,9 +11,6 @@ reana_yaml_valid_file_names = ["reana.yaml", "reana.yml"] """REANA specification valid file names.""" -default_user = "00000000-0000-0000-0000-000000000000" -"""Default user to use when submitting workflows to REANA Server.""" - ERROR_MESSAGES = { "missing_access_token": "Please provide your access token by using" " the -t/--access-token flag, or by setting the" diff --git a/tests/test_cli_workflows.py b/tests/test_cli_workflows.py index 0c244643..47a31dbc 100644 --- a/tests/test_cli_workflows.py +++ b/tests/test_cli_workflows.py @@ -17,12 +17,11 @@ from click.testing import CliRunner from mock import Mock, patch from pytest_reana.test_utils import make_mock_api_client -from reana_commons.config import INTERACTIVE_SESSION_TYPES - from reana_client.api.client import create_workflow_from_json -from reana_client.config import RUN_STATUSES from reana_client.cli import cli +from reana_client.config import RUN_STATUSES from reana_client.utils import get_workflow_status_change_msg +from reana_commons.config import INTERACTIVE_SESSION_TYPES def test_workflows_server_not_connected(): @@ -1011,3 +1010,42 @@ def test_run_with_no_inputs(spec_without_inputs): cli_workflow_start.assert_called() get_workflow_specification.assert_called() upload_to_server.assert_not_called() + + +def test_share_add_workflow(): + """Test share-add workflows.""" + status_code = 200 + response = { + "message": "is now read-only shared with", + "workflow_id": "string", + "workflow_name": "string", + } + env = {"REANA_SERVER_URL": "localhost"} + mock_http_response, mock_response = Mock(), Mock() + mock_http_response.status_code = status_code + mock_response = response + reana_token = "000000" + runner = CliRunner(env=env) + with runner.isolation(): + with patch( + "reana_client.api.client.current_rs_api_client", + make_mock_api_client("reana-server")(mock_response, mock_http_response), + ): + result = runner.invoke( + cli, + [ + "share-add", + "-t", + reana_token, + "--workflow", + "test-workflow.1", + "--user", + "bob@.cern.ch", + "--message", + "Test message", + "--valid-until", + "2024-01-01", + ], + ) + assert result.exit_code == 0 + assert response["message"] in result.output