Skip to content

Commit

Permalink
Add support for pause and resume operations
Browse files Browse the repository at this point in the history
Signed-off-by: Ada <ada@globus.org>
  • Loading branch information
ada-globus committed Sep 26, 2023
1 parent 84edaf8 commit b437c5e
Show file tree
Hide file tree
Showing 8 changed files with 300 additions and 5 deletions.
4 changes: 4 additions & 0 deletions changelog.d/20230822_165412_ada_sc_21799.md
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.
2 changes: 1 addition & 1 deletion setup.py
Original file line number Diff line number Diff line change
Expand Up @@ -45,7 +45,7 @@ def read_readme():
package_dir={"": "src"},
python_requires=">=3.7",
install_requires=[
"globus-sdk==3.27.0",
"globus-sdk==3.28.0",
"click>=8.1.4,<9",
"jmespath==1.0.1",
"packaging>=17.0",
Expand Down
4 changes: 3 additions & 1 deletion src/globus_cli/commands/timer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"""
23 changes: 23 additions & 0 deletions src/globus_cli/commands/timer/pause.py
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"],
)
87 changes: 87 additions & 0 deletions src/globus_cli/commands/timer/resume.py
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)
4 changes: 1 addition & 3 deletions tests/functional/flows/test_resume_run.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down
9 changes: 9 additions & 0 deletions tests/functional/timer/test_job_operations.py
Original file line number Diff line number Diff line change
Expand Up @@ -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']}.",
)
172 changes: 172 additions & 0 deletions tests/functional/timer/test_job_resume.py
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}.",
)

0 comments on commit b437c5e

Please sign in to comment.