diff --git a/changelog.d/20231215_140223_derek_gcs_subscription_id_sc_28587.md b/changelog.d/20231215_140223_derek_gcs_subscription_id_sc_28587.md new file mode 100644 index 000000000..41d8622e1 --- /dev/null +++ b/changelog.d/20231215_140223_derek_gcs_subscription_id_sc_28587.md @@ -0,0 +1,11 @@ + +### Enhancements + +* `[sc-28587] `_ + Added a new command `globus gcs endpoint set-subscription-id` which allows + subscription managers and endpoint admins to modify the subscription ID for a + GCS endpoint. + +* `[sc-28587] `_ + Added a new command `globus gcp set-subscription-id` which allows subscription + managers and collection admins to modify the subscription ID for a GCP collection. diff --git a/src/globus_cli/commands/endpoint/set_subscription_id.py b/src/globus_cli/commands/endpoint/set_subscription_id.py index 62ca3d540..278bf25db 100644 --- a/src/globus_cli/commands/endpoint/set_subscription_id.py +++ b/src/globus_cli/commands/endpoint/set_subscription_id.py @@ -28,7 +28,11 @@ def convert( self.fail(f"{value} is not a valid Subscription ID", param, ctx) -@command("set-subscription-id", short_help="Set an endpoint's subscription") +@command( + "set-subscription-id", + deprecated=True, + short_help="Set an endpoint's subscription", +) @endpoint_id_arg @click.argument("SUBSCRIPTION_ID", type=SubscriptionIdType()) @LoginManager.requires_login("transfer") @@ -36,6 +40,11 @@ def set_endpoint_subscription_id( login_manager: LoginManager, *, endpoint_id: uuid.UUID, subscription_id: str ) -> None: """ + For GCS endpoints, refer to ``globus gcs endpoint set-subscription-id``. For + GCP endpoints, refer to ``globus gcp set-subscription-id``. + + ----------------------------- + Set an endpoint's subscription ID. Unlike the '--managed' flag for 'globus endpoint update', this operation does not diff --git a/src/globus_cli/commands/gcp/__init__.py b/src/globus_cli/commands/gcp/__init__.py index ef094b7e4..5246eac36 100644 --- a/src/globus_cli/commands/gcp/__init__.py +++ b/src/globus_cli/commands/gcp/__init__.py @@ -6,6 +6,7 @@ lazy_subcommands={ "create": (".create", "create_command"), "update": (".update", "update_command"), + "set-subscription-id": (".set_subscription_id", "set_endpoint_subscription_id"), }, ) def gcp_command() -> None: diff --git a/src/globus_cli/commands/gcp/set_subscription_id.py b/src/globus_cli/commands/gcp/set_subscription_id.py new file mode 100644 index 000000000..b4e897acf --- /dev/null +++ b/src/globus_cli/commands/gcp/set_subscription_id.py @@ -0,0 +1,67 @@ +from __future__ import annotations + +import typing as t +import uuid + +import click + +from globus_cli.constants import EXPLICIT_NULL, ExplicitNullType +from globus_cli.endpointish import Endpointish, EntityType +from globus_cli.login_manager import LoginManager +from globus_cli.parsing import command, endpoint_id_arg +from globus_cli.termio import TextMode, display + + +class GCPSubscriptionIdType(click.ParamType): + def get_type_annotation(self, _: click.Parameter) -> type: + return t.cast(type, uuid.UUID | ExplicitNullType) + + def convert( + self, value: str, param: click.Parameter | None, ctx: click.Context | None + ) -> t.Any: + if ctx and ctx.resilient_parsing: + return None + + if value.lower() == "null": + return EXPLICIT_NULL + + try: + return uuid.UUID(value) + except ValueError: + msg = ( + f"{value} is invalid. Expected either a UUID or the special value " + '"null"' + ) + self.fail(msg, param, ctx) + + +@command("set-subscription-id", short_help="Update a GCP endpoint's subscription") +@endpoint_id_arg +@click.argument("SUBSCRIPTION_ID", type=GCPSubscriptionIdType()) +@LoginManager.requires_login("transfer") +def set_endpoint_subscription_id( + login_manager: LoginManager, + *, + endpoint_id: uuid.UUID, + subscription_id: uuid.UUID | ExplicitNullType, +) -> None: + """ + Update a GCP endpoint's subscription. + + This operation does not require you to be an admin of the endpoint. It is useful in + cases where you are a subscription manager applying a subscription to an endpoint + administered by someone else. + + SUBSCRIPTION_ID must be one of: (1) A valid subscription ID (UUID) or (2) the value + "null" (clears the endpoint's subscription). + """ + transfer_client = login_manager.get_transfer_client() + epish = Endpointish(endpoint_id, transfer_client=transfer_client) + epish.assert_entity_type(expect_types=EntityType.GCP_MAPPED) + + res = transfer_client.put( + f"/endpoint/{endpoint_id}/subscription", + data={"subscription_id": ExplicitNullType.nullify(subscription_id)}, + ) + + display(res, text_mode=TextMode.text_raw, response_key="message") diff --git a/src/globus_cli/commands/gcs/__init__.py b/src/globus_cli/commands/gcs/__init__.py index 7a0457136..5b3a23c8a 100644 --- a/src/globus_cli/commands/gcs/__init__.py +++ b/src/globus_cli/commands/gcs/__init__.py @@ -5,9 +5,12 @@ "gcs", lazy_subcommands={ "collection": ("collection", "collection_command"), + # Note: endpoint is not an alias for the root 'endpoint' group as that group is + # broken up into slightly different subcommand structures here. + "endpoint": (".endpoint", "endpoint_command"), "storage-gateway": ("endpoint.storage_gateway", "storage_gateway_command"), "user-credential": ("endpoint.user_credential", "user_credential_command"), }, ) def gcs_command() -> None: - """Manage Globus Connect Server endpoints""" + """Manage Globus Connect Server (GCS) resources""" diff --git a/src/globus_cli/commands/gcs/endpoint/__init__.py b/src/globus_cli/commands/gcs/endpoint/__init__.py new file mode 100644 index 000000000..a85a584d6 --- /dev/null +++ b/src/globus_cli/commands/gcs/endpoint/__init__.py @@ -0,0 +1,11 @@ +from globus_cli.parsing import group + + +@group( + "endpoint", + lazy_subcommands={ + "set-subscription-id": (".set_subscription_id", "set_subscription_id_command"), + }, +) +def endpoint_command() -> None: + """Manage Globus Connect Server (GCS) endpoints""" diff --git a/src/globus_cli/commands/gcs/endpoint/set_subscription_id.py b/src/globus_cli/commands/gcs/endpoint/set_subscription_id.py new file mode 100644 index 000000000..593994206 --- /dev/null +++ b/src/globus_cli/commands/gcs/endpoint/set_subscription_id.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +import typing as t +import uuid + +import click + +from globus_cli.constants import EXPLICIT_NULL, ExplicitNullType +from globus_cli.login_manager import LoginManager +from globus_cli.parsing import command, endpoint_id_arg +from globus_cli.termio import TextMode, display + + +class GCSSubscriptionIdType(click.ParamType): + def get_type_annotation(self, _: click.Parameter) -> type: + return t.cast(type, uuid.UUID | t.Literal["DEFAULT"] | ExplicitNullType) + + def convert( + self, value: str, param: click.Parameter | None, ctx: click.Context | None + ) -> t.Any: + if ctx and ctx.resilient_parsing: + return None + + if value.lower() == "null": + return EXPLICIT_NULL + elif value.lower() == "default": + return "DEFAULT" + try: + return uuid.UUID(value) + except ValueError: + msg = ( + f"{value} is invalid. Expected either a UUID or the special " + 'values "DEFAULT" or "null"' + ) + self.fail(msg, param, ctx) + + +@command("set-subscription-id", short_help="Update an endpoint's subscription") +@endpoint_id_arg +@click.argument("SUBSCRIPTION_ID", type=GCSSubscriptionIdType()) +@LoginManager.requires_login("transfer") +def set_subscription_id_command( + login_manager: LoginManager, + *, + endpoint_id: uuid.UUID, + subscription_id: uuid.UUID | t.Literal["DEFAULT"] | ExplicitNullType, +) -> None: + """ + Update an endpoint's subscription. + + SUBSCRIPTION_ID must be one of: (1) A valid subscription ID (UUID), (2) the value + "DEFAULT" (requires that you manage exactly one subscription & assigns the endpoint + to that subscription), or (3) the value "null" (clears the endpoint's subscription). + + Setting a subscription requires that you are a subscription manager for the + subscription being assigned. + + Removing a subscription requires that you are either (1) a subscription manager for + the current assigned subscription group or (2) an admin of the endpoint. + """ + gcs_client = login_manager.get_gcs_client(endpoint_id=endpoint_id) + + subscription_id_val = None if subscription_id is EXPLICIT_NULL else subscription_id + res = gcs_client.put( + "/endpoint/subscription_id", + data={ + "DATA_TYPE": "endpoint_subscription#1.0.0", + "subscription_id": subscription_id_val, + }, + ) + + display(res, text_mode=TextMode.text_raw, response_key="message") diff --git a/tests/functional/gcs/__init__.py b/tests/functional/gcs/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/functional/gcs/endpoint/__init__.py b/tests/functional/gcs/endpoint/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/functional/gcs/endpoint/test_set_subscription_id.py b/tests/functional/gcs/endpoint/test_set_subscription_id.py new file mode 100644 index 000000000..96d501c4a --- /dev/null +++ b/tests/functional/gcs/endpoint/test_set_subscription_id.py @@ -0,0 +1,64 @@ +import uuid + +import pytest +import responses +from globus_sdk._testing import load_response_set + + +@pytest.mark.parametrize("subscription_id", (str(uuid.uuid4()), "DEFAULT", "null")) +def test_gcs_endpoint_set_subscription_id(subscription_id, run_line, add_gcs_login): + meta = load_response_set("cli.collection_operations").metadata + endpoint_id = meta["endpoint_id"] + gcs_hostname = meta["gcs_hostname"] + add_gcs_login(endpoint_id) + + responses.put( + f"https://{gcs_hostname}/api/endpoint/subscription_id", + json={ + "DATA_TYPE": "result#1.0.0", + "code": "success", + "detail": "success", + "has_next_page": False, + "http_response_code": 200, + "message": f"Updated Endpoint {endpoint_id}", + }, + ) + + result = run_line( + f"globus gcs endpoint set-subscription-id {endpoint_id} {subscription_id}" + ) + + assert f"Updated Endpoint {endpoint_id}" in result.stdout + + +def test_gcs_endpoint_set_subscription_id__when_not_subscription_manager( + run_line, add_gcs_login +): + meta = load_response_set("cli.collection_operations").metadata + endpoint_id = meta["endpoint_id"] + gcs_hostname = meta["gcs_hostname"] + add_gcs_login(endpoint_id) + + error_message = ( + "Unable to use DEFAULT subscription. Your identity does not manage any" + "subscriptions" + ) + responses.put( + f"https://{gcs_hostname}/api/endpoint/subscription_id", + status=400, + json={ + "DATA_TYPE": "result#1.0.0", + "code": "bad_request", + "detail": "bad_request", + "has_next_page": False, + "http_response_code": 400, + "message": error_message, + }, + ) + + result = run_line( + f"globus gcs endpoint set-subscription-id {endpoint_id} DEFAULT", + assert_exit_code=1, + ) + + assert error_message in result.stderr