-
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
Signed-off-by: Ada <ada@globus.org>
- Loading branch information
1 parent
84edaf8
commit b437c5e
Showing
8 changed files
with
300 additions
and
5 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
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,87 @@ | ||
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) | ||
|
||
if not skip_inactive_reason_check: | ||
gare = _get_inactive_reason(job_doc) | ||
if gare is not None and gare.authorization_parameters.required_scopes: | ||
if not _has_required_consent( | ||
login_manager, gare.authorization_parameters.required_scopes | ||
): | ||
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=(not skip_inactive_reason_check), | ||
) | ||
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 not 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,172 @@ | ||
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_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}.", | ||
) |