Skip to content

Commit

Permalink
feat(workflow): add share-add command (reanahub#692)
Browse files Browse the repository at this point in the history
Adds a new command to the CLI to share a workflow with a user.

Closes reanahub#680
  • Loading branch information
DaanRosendal authored and tiborsimko committed Sep 4, 2024
1 parent cd45ad5 commit f7b53e7
Show file tree
Hide file tree
Showing 7 changed files with 193 additions and 28 deletions.
1 change: 1 addition & 0 deletions AUTHORS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions docs/cmd_list.txt
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 57 additions & 10 deletions reana_client/api/client.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, 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.
Expand All @@ -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")
)
Expand Down Expand Up @@ -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"])
8 changes: 4 additions & 4 deletions reana_client/cli/__init__.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, 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.
Expand All @@ -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 "
Expand All @@ -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,
Expand Down
95 changes: 87 additions & 8 deletions reana_client/cli/workflow.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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")
Expand All @@ -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",
Expand Down Expand Up @@ -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."""
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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 [email protected]
\t $ reana-client share-add -w myanalysis.42 --user [email protected] --user [email protected] --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)
3 changes: 0 additions & 3 deletions reana_client/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
44 changes: 41 additions & 3 deletions tests/test_cli_workflows.py
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down Expand Up @@ -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",
"[email protected]",
"--message",
"Test message",
"--valid-until",
"2024-01-01",
],
)
assert result.exit_code == 0
assert response["message"] in result.output

0 comments on commit f7b53e7

Please sign in to comment.