-
Notifications
You must be signed in to change notification settings - Fork 22
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Add support for pause and resume operations (#873)
* Add support for pause and resume operations Signed-off-by: Ada <ada@globus.org> * Fix help text Signed-off-by: Ada <ada@globus.org> * Fix inactive reason comparison Signed-off-by: Ada <ada@globus.org> * Change skip-inactive-reason to suppress error only Signed-off-by: Ada <ada@globus.org> --------- Signed-off-by: Ada <ada@globus.org>
- Loading branch information
1 parent
8fad255
commit e9e2f18
Showing
7 changed files
with
311 additions
and
4 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,4 @@ | ||
### Enhancements | ||
|
||
* Add new commands to support pausing (`globus timer pause`) and resuming | ||
(`globus timer resume`) Timers jobs. |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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"], | ||
) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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}.", | ||
) |