From e9e2f183025b907cdda2afa5ea1288b4f6872f26 Mon Sep 17 00:00:00 2001 From: Ada <107940310+ada-globus@users.noreply.github.com> Date: Tue, 3 Oct 2023 08:35:29 -0700 Subject: [PATCH] Add support for pause and resume operations (#873) * Add support for pause and resume operations Signed-off-by: Ada * Fix help text Signed-off-by: Ada * Fix inactive reason comparison Signed-off-by: Ada * Change skip-inactive-reason to suppress error only Signed-off-by: Ada --------- Signed-off-by: Ada --- changelog.d/20230822_165412_ada_sc_21799.md | 4 + src/globus_cli/commands/timer/__init__.py | 4 +- src/globus_cli/commands/timer/pause.py | 23 +++ src/globus_cli/commands/timer/resume.py | 89 +++++++++ tests/functional/flows/test_resume_run.py | 4 +- tests/functional/timer/test_job_operations.py | 9 + tests/functional/timer/test_job_resume.py | 182 ++++++++++++++++++ 7 files changed, 311 insertions(+), 4 deletions(-) create mode 100644 changelog.d/20230822_165412_ada_sc_21799.md create mode 100644 src/globus_cli/commands/timer/pause.py create mode 100644 src/globus_cli/commands/timer/resume.py create mode 100644 tests/functional/timer/test_job_resume.py diff --git a/changelog.d/20230822_165412_ada_sc_21799.md b/changelog.d/20230822_165412_ada_sc_21799.md new file mode 100644 index 000000000..ba103066d --- /dev/null +++ b/changelog.d/20230822_165412_ada_sc_21799.md @@ -0,0 +1,4 @@ +### Enhancements + +* Add new commands to support pausing (`globus timer pause`) and resuming + (`globus timer resume`) Timers jobs. diff --git a/src/globus_cli/commands/timer/__init__.py b/src/globus_cli/commands/timer/__init__.py index fd0e86abe..07e6759ef 100644 --- a/src/globus_cli/commands/timer/__init__.py +++ b/src/globus_cli/commands/timer/__init__.py @@ -7,8 +7,10 @@ "create": (".create", "create_command"), "delete": (".delete", "delete_command"), "list": (".list", "list_command"), + "pause": (".pause", "pause_command"), + "resume": (".resume", "resume_command"), "show": (".show", "show_command"), }, ) def timer_command() -> None: - """Schedule and manage jobs in Globus Timer""" + """Schedule and manage jobs in Globus Timers""" diff --git a/src/globus_cli/commands/timer/pause.py b/src/globus_cli/commands/timer/pause.py new file mode 100644 index 000000000..d083d2a5f --- /dev/null +++ b/src/globus_cli/commands/timer/pause.py @@ -0,0 +1,23 @@ +import uuid + +import click + +from globus_cli.login_manager import LoginManager +from globus_cli.parsing import command +from globus_cli.termio import TextMode, display + + +@command("pause", short_help="Pause a timer") +@click.argument("JOB_ID", type=click.UUID) +@LoginManager.requires_login("timer") +def pause_command(login_manager: LoginManager, *, job_id: uuid.UUID) -> None: + """ + Pause a timer. + """ + timer_client = login_manager.get_timer_client() + paused = timer_client.pause_job(job_id) + display( + paused, + text_mode=TextMode.text_raw, + simple_text=paused["message"], + ) diff --git a/src/globus_cli/commands/timer/resume.py b/src/globus_cli/commands/timer/resume.py new file mode 100644 index 000000000..82836598a --- /dev/null +++ b/src/globus_cli/commands/timer/resume.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +import typing as t +import uuid + +import click +import globus_sdk + +from globus_cli.login_manager import LoginManager, read_well_known_config +from globus_cli.parsing import command +from globus_cli.termio import TextMode, display +from globus_cli.utils import CLIAuthRequirementsError + +# NB: GARE parsing requires other SDK components and therefore needs to be deferred to +# avoid the performance impact of non-lazy imports +if t.TYPE_CHECKING: + from globus_sdk.experimental.auth_requirements_error import ( + GlobusAuthRequirementsError, + ) + + +@command("resume", short_help="Resume a timer") +@click.argument("JOB_ID", type=click.UUID) +@click.option( + "--skip-inactive-reason-check", + is_flag=True, + default=False, + help=( + 'Skip the check of the timer\'s "inactive reason", which is used to determine ' + "if additional steps are required to successfully resume the timer." + ), +) +@LoginManager.requires_login("timer") +def resume_command( + login_manager: LoginManager, *, job_id: uuid.UUID, skip_inactive_reason_check: bool +) -> None: + """ + Resume a timer. + """ + timer_client = login_manager.get_timer_client() + job_doc = timer_client.get_job(job_id) + + gare = _get_inactive_reason(job_doc) + if gare is not None and gare.authorization_parameters.required_scopes: + consent_required = not _has_required_consent( + login_manager, gare.authorization_parameters.required_scopes + ) + if consent_required and not skip_inactive_reason_check: + raise CLIAuthRequirementsError( + "This run is missing a necessary consent in order to resume.", + required_scopes=gare.authorization_parameters.required_scopes, + ) + + resumed = timer_client.resume_job( + job_id, + update_credentials=(gare is not None), + ) + display( + resumed, + text_mode=TextMode.text_raw, + simple_text=resumed["message"], + ) + + +def _get_inactive_reason( + job_doc: dict[str, t.Any] | globus_sdk.GlobusHTTPResponse +) -> GlobusAuthRequirementsError | None: + from globus_sdk.experimental.auth_requirements_error import ( + to_auth_requirements_error, + ) + + if job_doc.get("status") != "inactive": + return None + + reason = job_doc.get("inactive_reason", {}) + if reason.get("cause") != "globus_auth_requirements": + return None + + return to_auth_requirements_error(reason.get("detail", {})) + + +def _has_required_consent( + login_manager: LoginManager, required_scopes: list[str] +) -> bool: + auth_client = login_manager.get_auth_client() + user_data = read_well_known_config("auth_user_data", allow_null=False) + user_identity_id = user_data["sub"] + consents = auth_client.get_consents(user_identity_id) + return consents.contains_scopes(required_scopes) diff --git a/tests/functional/flows/test_resume_run.py b/tests/functional/flows/test_resume_run.py index a9191221a..a14c76fd8 100644 --- a/tests/functional/flows/test_resume_run.py +++ b/tests/functional/flows/test_resume_run.py @@ -26,9 +26,7 @@ def _register_responses(mock_user_data): transfer_scope = globus_sdk.TransferClient.scopes.all flow_scope = _urlscope(flow_id, f"flow_{flow_id.replace('-', '_')}_user") data_access_scope = _urlscope(collection_id, "data_access") - full_data_access_scope = ( - f"{transfer_scope}[*{_urlscope(collection_id, 'data_access')}]" - ) + full_data_access_scope = f"{transfer_scope}[*{data_access_scope}]" required_scope = f"{flow_scope}[{full_data_access_scope}]" metadata = { diff --git a/tests/functional/timer/test_job_operations.py b/tests/functional/timer/test_job_operations.py index 4ef3f0ae4..8ec1d936e 100644 --- a/tests/functional/timer/test_job_operations.py +++ b/tests/functional/timer/test_job_operations.py @@ -97,3 +97,12 @@ def test_delete_job(run_line, out_format): r"^Job ID:\s+" + re.escape(meta["job_id"]) + "$", flags=re.MULTILINE ) assert pattern.search(result.output) is not None + + +def test_pause_job(run_line): + meta = load_response_set(globus_sdk.TimerClient.pause_job).metadata + add_args = [] + run_line( + ["globus", "timer", "pause", meta["job_id"]] + add_args, + search_stdout=f"Successfully paused job {meta['job_id']}.", + ) diff --git a/tests/functional/timer/test_job_resume.py b/tests/functional/timer/test_job_resume.py new file mode 100644 index 000000000..9bbc0db73 --- /dev/null +++ b/tests/functional/timer/test_job_resume.py @@ -0,0 +1,182 @@ +import uuid + +import globus_sdk +import pytest +from globus_sdk._testing import load_response_set, register_response_set +from globus_sdk._testing.data.timer.get_job import JOB_JSON +from globus_sdk._testing.data.timer.get_job import RESPONSES as TIMERS_GET_RESPONSES +from globus_sdk._testing.data.timer.resume_job import ( + RESPONSES as TIMERS_RESUME_RESPONSES, +) + + +def _urlscope(m: str, s: str) -> str: + return f"https://auth.globus.org/scopes/{m}/{s}" + + +@pytest.fixture(scope="session", autouse=True) +def _register_responses(mock_user_data): + # Note: this value must match so that the mock login data matches the responses + user_id = mock_user_data["sub"] + job_id = str(uuid.uuid1()) + collection_id = str(uuid.uuid1()) + transfer_scope = globus_sdk.TransferClient.scopes.all + timers_scope = globus_sdk.TimerClient.scopes.timer + transfer_ap_scope = _urlscope("actions.globus.org/transfer", "transfer") + data_access_scope = _urlscope(collection_id, "data_access") + full_data_access_scope = ( + f"{transfer_ap_scope}[{transfer_scope}[*{data_access_scope}]]" + ) + required_scope = f"{timers_scope}[{full_data_access_scope}]" + + metadata = { + "user_id": user_id, + "job_id": job_id, + "collection_id": collection_id, + "required_scope": required_scope, + } + + get_job_json_inactive_gare_body = { + **JOB_JSON, + "status": "inactive", + "inactive_reason": { + "cause": "globus_auth_requirements", + "detail": { + "code": "ConsentRequired", + "authorization_parameters": { + "session_message": "Missing required data_access consent", + "required_scopes": [required_scope], + }, + }, + }, + } + + register_response_set( + "cli.timer_resume.inactive_gare.consents_missing", + dict( + get_job=dict( + service="timer", + path=f"/jobs/{job_id}", + method="GET", + json=get_job_json_inactive_gare_body, + ), + resume=dict( + service="timer", + path=f"/jobs/{job_id}/resume", + method="POST", + json={"message": f"Successfully resumed job {job_id}."}, + ), + consents=dict( + service="auth", + path=f"/v2/api/identities/{user_id}/consents", + method="GET", + json={ + "consents": [ + { + "scope_name": timers_scope, + "dependency_path": [100], + "id": 100, + } + ] + }, + ), + ), + metadata=metadata, + ) + + register_response_set( + "cli.timer_resume.inactive_gare.consents_present", + dict( + get_job=dict( + service="timer", + path=f"/jobs/{job_id}", + method="GET", + json=get_job_json_inactive_gare_body, + ), + resume=dict( + service="timer", + path=f"/jobs/{job_id}/resume", + method="POST", + json={"message": f"Successfully resumed job {job_id}."}, + ), + consents=dict( + service="auth", + path=f"/v2/api/identities/{user_id}/consents", + method="GET", + json={ + "consents": [ + { + "scope_name": timers_scope, + "dependency_path": [100], + "id": 100, + }, + { + "scope_name": transfer_ap_scope, + "dependency_path": [100, 101], + "id": 101, + }, + { + "scope_name": transfer_scope, + "dependency_path": [100, 101, 102], + "id": 102, + }, + { + "scope_name": data_access_scope, + "dependency_path": [100, 101, 102, 103], + "id": 103, + }, + ] + }, + ), + ), + metadata=metadata, + ) + + +def test_resume_job_active(run_line): + TIMERS_GET_RESPONSES.activate("default") + TIMERS_RESUME_RESPONSES.activate("default") + job_id = TIMERS_GET_RESPONSES.metadata["job_id"] + run_line( + ["globus", "timer", "resume", job_id], + search_stdout=f"Successfully resumed job {job_id}.", + ) + + +def test_resume_job_inactive_user(run_line): + TIMERS_GET_RESPONSES.activate("inactive_user") + TIMERS_RESUME_RESPONSES.activate("default") + job_id = TIMERS_GET_RESPONSES.metadata["job_id"] + run_line( + ["globus", "timer", "resume", job_id], + search_stdout=f"Successfully resumed job {job_id}.", + ) + + +def test_resume_job_inactive_gare_consent_missing(run_line): + meta = load_response_set("cli.timer_resume.inactive_gare.consents_missing").metadata + job_id = meta["job_id"] + required_scope = meta["required_scope"] + result = run_line( + ["globus", "timer", "resume", job_id], + assert_exit_code=4, + ) + assert f"globus session consent '{required_scope}'" in result.output + + +def test_resume_job_inactive_gare_consent_present(run_line): + meta = load_response_set("cli.timer_resume.inactive_gare.consents_present").metadata + job_id = meta["job_id"] + run_line( + ["globus", "timer", "resume", job_id], + search_stdout=f"Successfully resumed job {job_id}.", + ) + + +def test_resume_job_inactive_gare_consent_missing_but_skip_check(run_line): + meta = load_response_set("cli.timer_resume.inactive_gare.consents_missing").metadata + job_id = meta["job_id"] + run_line( + ["globus", "timer", "resume", "--skip-inactive-reason-check", job_id], + search_stdout=f"Successfully resumed job {job_id}.", + )