Repository: getmoto/moto
Pull request #7846 (2024-07-11 19:52:37)

Feature: Add RTAPI tag resource for SageMaker
Adds RTAPI tag_resource support for sagemaker

codecov[bot]: ## [Codecov](https://app.codecov.io/gh/getmoto/moto/pull/7846?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 94.33%. Comparing base [(`1c8c0fe`)](https://app.codecov.io/gh/getmoto/moto/commit/1c8c0fe4d9c7f5481a40814a8a6459a5142707ff?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) to head [(`dcde3dc`)](https://app.codecov.io/gh/getmoto/moto/commit/dcde3dcf72507a6c4d88558e9cda186c74d51100?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).

<details><summary>Additional details and impacted files</summary>


```diff
@@           Coverage Diff           @@
##           master    #7846   +/-   ##
=======================================
  Coverage   94.33%   94.33%           
=======================================
  Files        1107     1107           
  Lines       94268    94270    +2     
=======================================
+ Hits        88929    88934    +5     
+ Misses       5339     5336    -3     
```

| [Flag](https://app.codecov.io/gh/getmoto/moto/pull/7846/flags?src=pr&el=flags&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | Coverage Δ | |
|---|---|---|
| [servertests](https://app.codecov.io/gh/getmoto/moto/pull/7846/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `29.29% <0.00%> (-0.01%)` | :arrow_down: |
| [unittests](https://app.codecov.io/gh/getmoto/moto/pull/7846/flags?src=pr&el=flag&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto) | `94.30% <100.00%> (+<0.01%)` | :arrow_up: |

Flags with carried forward coverage won't be shown. [Click here](https://docs.codecov.io/docs/carryforward-flags?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto#carryforward-flags-in-the-pull-request-comment) to find out more.

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/getmoto/moto/pull/7846?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=getmoto).


Patch:
diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md
index e6dedaaf9..873cec628 100644
--- a/IMPLEMENTATION_COVERAGE.md
+++ b/IMPLEMENTATION_COVERAGE.md
@@ -1852,6 +1852,75 @@
 - [ ] update_subnet_group
 </details>
 
+## directconnect
+<details>
+<summary>6% implemented</summary>
+
+- [ ] accept_direct_connect_gateway_association_proposal
+- [ ] allocate_connection_on_interconnect
+- [ ] allocate_hosted_connection
+- [ ] allocate_private_virtual_interface
+- [ ] allocate_public_virtual_interface
+- [ ] allocate_transit_virtual_interface
+- [ ] associate_connection_with_lag
+- [ ] associate_hosted_connection
+- [ ] associate_mac_sec_key
+- [ ] associate_virtual_interface
+- [ ] confirm_connection
+- [ ] confirm_customer_agreement
+- [ ] confirm_private_virtual_interface
+- [ ] confirm_public_virtual_interface
+- [ ] confirm_transit_virtual_interface
+- [ ] create_bgp_peer
+- [X] create_connection
+- [ ] create_direct_connect_gateway
+- [ ] create_direct_connect_gateway_association
+- [ ] create_direct_connect_gateway_association_proposal
+- [ ] create_interconnect
+- [ ] create_lag
+- [ ] create_private_virtual_interface
+- [ ] create_public_virtual_interface
+- [ ] create_transit_virtual_interface
+- [ ] delete_bgp_peer
+- [X] delete_connection
+- [ ] delete_direct_connect_gateway
+- [ ] delete_direct_connect_gateway_association
+- [ ] delete_direct_connect_gateway_association_proposal
+- [ ] delete_interconnect
+- [ ] delete_lag
+- [ ] delete_virtual_interface
+- [ ] describe_connection_loa
+- [X] describe_connections
+- [ ] describe_connections_on_interconnect
+- [ ] describe_customer_metadata
+- [ ] describe_direct_connect_gateway_association_proposals
+- [ ] describe_direct_connect_gateway_associations
+- [ ] describe_direct_connect_gateway_attachments
+- [ ] describe_direct_connect_gateways
+- [ ] describe_hosted_connections
+- [ ] describe_interconnect_loa
+- [ ] describe_interconnects
+- [ ] describe_lags
+- [ ] describe_loa
+- [ ] describe_locations
+- [ ] describe_router_configuration
+- [ ] describe_tags
+- [ ] describe_virtual_gateways
+- [ ] describe_virtual_interfaces
+- [ ] disassociate_connection_from_lag
+- [ ] disassociate_mac_sec_key
+- [ ] list_virtual_interface_test_history
+- [ ] start_bgp_failover_test
+- [ ] stop_bgp_failover_test
+- [ ] tag_resource
+- [ ] untag_resource
+- [X] update_connection
+- [ ] update_direct_connect_gateway
+- [ ] update_direct_connect_gateway_association
+- [ ] update_lag
+- [ ] update_virtual_interface_attributes
+</details>
+
 ## dms
 <details>
 <summary>4% implemented</summary>
@@ -7163,6 +7232,7 @@
 - [ ] create_monitoring_schedule
 - [X] create_notebook_instance
 - [X] create_notebook_instance_lifecycle_config
+- [ ] create_optimization_job
 - [X] create_pipeline
 - [ ] create_presigned_domain_url
 - [ ] create_presigned_mlflow_tracking_server_url
@@ -7219,6 +7289,7 @@
 - [ ] delete_monitoring_schedule
 - [X] delete_notebook_instance
 - [X] delete_notebook_instance_lifecycle_config
+- [ ] delete_optimization_job
 - [X] delete_pipeline
 - [ ] delete_project
 - [ ] delete_space
@@ -7277,6 +7348,7 @@
 - [ ] describe_monitoring_schedule
 - [ ] describe_notebook_instance
 - [X] describe_notebook_instance_lifecycle_config
+- [ ] describe_optimization_job
 - [X] describe_pipeline
 - [X] describe_pipeline_definition_for_execution
 - [X] describe_pipeline_execution
@@ -7358,6 +7430,7 @@
 - [ ] list_monitoring_schedules
 - [ ] list_notebook_instance_lifecycle_configs
 - [X] list_notebook_instances
+- [ ] list_optimization_jobs
 - [ ] list_pipeline_execution_steps
 - [X] list_pipeline_executions
 - [X] list_pipeline_parameters_for_execution
@@ -7403,6 +7476,7 @@
 - [ ] stop_mlflow_tracking_server
 - [ ] stop_monitoring_schedule
 - [X] stop_notebook_instance
+- [ ] stop_optimization_job
 - [ ] stop_pipeline_execution
 - [ ] stop_processing_job
 - [ ] stop_training_job
@@ -8588,7 +8662,6 @@
 - detective
 - devicefarm
 - devops-guru
-- directconnect
 - discovery
 - dlm
 - docdb
@@ -8706,6 +8779,7 @@
 - pricing
 - privatenetworks
 - proton
+- qapps
 - qbusiness
 - qconnect
 - qldb
diff --git a/docs/docs/services/directconnect.rst b/docs/docs/services/directconnect.rst
new file mode 100644
index 000000000..5339e3942
--- /dev/null
+++ b/docs/docs/services/directconnect.rst
@@ -0,0 +1,82 @@
+.. _implementedservice_directconnect:
+
+.. |start-h3| raw:: html
+
+    <h3>
+
+.. |end-h3| raw:: html
+
+    </h3>
+
+=============
+directconnect
+=============
+
+.. autoclass:: moto.directconnect.models.DirectConnectBackend
+
+|start-h3| Implemented features for this service |end-h3|
+
+- [ ] accept_direct_connect_gateway_association_proposal
+- [ ] allocate_connection_on_interconnect
+- [ ] allocate_hosted_connection
+- [ ] allocate_private_virtual_interface
+- [ ] allocate_public_virtual_interface
+- [ ] allocate_transit_virtual_interface
+- [ ] associate_connection_with_lag
+- [ ] associate_hosted_connection
+- [ ] associate_mac_sec_key
+- [ ] associate_virtual_interface
+- [ ] confirm_connection
+- [ ] confirm_customer_agreement
+- [ ] confirm_private_virtual_interface
+- [ ] confirm_public_virtual_interface
+- [ ] confirm_transit_virtual_interface
+- [ ] create_bgp_peer
+- [X] create_connection
+- [ ] create_direct_connect_gateway
+- [ ] create_direct_connect_gateway_association
+- [ ] create_direct_connect_gateway_association_proposal
+- [ ] create_interconnect
+- [ ] create_lag
+- [ ] create_private_virtual_interface
+- [ ] create_public_virtual_interface
+- [ ] create_transit_virtual_interface
+- [ ] delete_bgp_peer
+- [X] delete_connection
+- [ ] delete_direct_connect_gateway
+- [ ] delete_direct_connect_gateway_association
+- [ ] delete_direct_connect_gateway_association_proposal
+- [ ] delete_interconnect
+- [ ] delete_lag
+- [ ] delete_virtual_interface
+- [ ] describe_connection_loa
+- [X] describe_connections
+- [ ] describe_connections_on_interconnect
+- [ ] describe_customer_metadata
+- [ ] describe_direct_connect_gateway_association_proposals
+- [ ] describe_direct_connect_gateway_associations
+- [ ] describe_direct_connect_gateway_attachments
+- [ ] describe_direct_connect_gateways
+- [ ] describe_hosted_connections
+- [ ] describe_interconnect_loa
+- [ ] describe_interconnects
+- [ ] describe_lags
+- [ ] describe_loa
+- [ ] describe_locations
+- [ ] describe_router_configuration
+- [ ] describe_tags
+- [ ] describe_virtual_gateways
+- [ ] describe_virtual_interfaces
+- [ ] disassociate_connection_from_lag
+- [ ] disassociate_mac_sec_key
+- [ ] list_virtual_interface_test_history
+- [ ] start_bgp_failover_test
+- [ ] stop_bgp_failover_test
+- [ ] tag_resource
+- [ ] untag_resource
+- [X] update_connection
+- [ ] update_direct_connect_gateway
+- [ ] update_direct_connect_gateway_association
+- [ ] update_lag
+- [ ] update_virtual_interface_attributes
+
diff --git a/docs/docs/services/sagemaker.rst b/docs/docs/services/sagemaker.rst
index 18dc449fe..a0f0f34c8 100644
--- a/docs/docs/services/sagemaker.rst
+++ b/docs/docs/services/sagemaker.rst
@@ -62,6 +62,7 @@ sagemaker
 - [ ] create_monitoring_schedule
 - [X] create_notebook_instance
 - [X] create_notebook_instance_lifecycle_config
+- [ ] create_optimization_job
 - [X] create_pipeline
 - [ ] create_presigned_domain_url
 - [ ] create_presigned_mlflow_tracking_server_url
@@ -118,6 +119,7 @@ sagemaker
 - [ ] delete_monitoring_schedule
 - [X] delete_notebook_instance
 - [X] delete_notebook_instance_lifecycle_config
+- [ ] delete_optimization_job
 - [X] delete_pipeline
 - [ ] delete_project
 - [ ] delete_space
@@ -176,6 +178,7 @@ sagemaker
 - [ ] describe_monitoring_schedule
 - [ ] describe_notebook_instance
 - [X] describe_notebook_instance_lifecycle_config
+- [ ] describe_optimization_job
 - [X] describe_pipeline
 - [X] describe_pipeline_definition_for_execution
 - [X] describe_pipeline_execution
@@ -262,6 +265,7 @@ sagemaker
         CreationTimeBefore, CreationTimeAfter, LastModifiedTimeBefore, LastModifiedTimeAfter, NotebookInstanceLifecycleConfigNameContains, DefaultCodeRepositoryContains, AdditionalCodeRepositoryEquals
         
 
+- [ ] list_optimization_jobs
 - [ ] list_pipeline_execution_steps
 - [X] list_pipeline_executions
 - [X] list_pipeline_parameters_for_execution
@@ -307,6 +311,7 @@ sagemaker
 - [ ] stop_mlflow_tracking_server
 - [ ] stop_monitoring_schedule
 - [X] stop_notebook_instance
+- [ ] stop_optimization_job
 - [ ] stop_pipeline_execution
 - [ ] stop_processing_job
 - [ ] stop_training_job
diff --git a/moto/backend_index.py b/moto/backend_index.py
index 6b63bea93..2a552ba00 100644
--- a/moto/backend_index.py
+++ b/moto/backend_index.py
@@ -41,6 +41,7 @@ backend_url_patterns = [
     ("datapipeline", re.compile("https?://datapipeline\\.(.+)\\.amazonaws\\.com")),
     ("datasync", re.compile("https?://(.*\\.)?(datasync)\\.(.+)\\.amazonaws.com")),
     ("dax", re.compile("https?://dax\\.(.+)\\.amazonaws\\.com")),
+    ("directconnect", re.compile("https?://directconnect\\.(.+)\\.amazonaws\\.com")),
     ("dms", re.compile("https?://dms\\.(.+)\\.amazonaws\\.com")),
     ("ds", re.compile("https?://ds\\.(.+)\\.amazonaws\\.com")),
     ("dynamodb", re.compile("https?://dynamodb\\.(.+)\\.amazonaws\\.com")),
diff --git a/moto/backends.py b/moto/backends.py
index 4ee3709f2..1f8fa4a50 100644
--- a/moto/backends.py
+++ b/moto/backends.py
@@ -40,6 +40,7 @@ if TYPE_CHECKING:
     from moto.datapipeline.models import DataPipelineBackend
     from moto.datasync.models import DataSyncBackend
     from moto.dax.models import DAXBackend
+    from moto.directconnect.models import DirectConnectBackend
     from moto.dms.models import DatabaseMigrationServiceBackend
     from moto.ds.models import DirectoryServiceBackend
     from moto.dynamodb.models import DynamoDBBackend
@@ -208,6 +209,7 @@ SERVICE_NAMES = Union[
     "Literal['datapipeline']",
     "Literal['datasync']",
     "Literal['dax']",
+    "Literal['directconnect']",
     "Literal['dms']",
     "Literal['ds']",
     "Literal['dynamodb']",
@@ -410,6 +412,10 @@ def get_backend(
     name: "Literal['dms']",
 ) -> "BackendDict[DatabaseMigrationServiceBackend]": ...
 @overload
+def get_backend(
+    name: "Literal['directconnect']",
+) -> "BackendDict[DirectConnectBackend]": ...
+@overload
 def get_backend(name: "Literal['ds']") -> "BackendDict[DirectoryServiceBackend]": ...
 @overload
 def get_backend(name: "Literal['dynamodb']") -> "BackendDict[DynamoDBBackend]": ...
diff --git a/moto/directconnect/__init__.py b/moto/directconnect/__init__.py
new file mode 100644
index 000000000..da3916010
--- /dev/null
+++ b/moto/directconnect/__init__.py
@@ -0,0 +1,1 @@
+from .models import directconnect_backends  # noqa: F401
diff --git a/moto/directconnect/enums.py b/moto/directconnect/enums.py
new file mode 100644
index 000000000..becce7976
--- /dev/null
+++ b/moto/directconnect/enums.py
@@ -0,0 +1,31 @@
+from enum import Enum
+
+
+class ConnectionStateType(str, Enum):
+    AVAILABLE = "available"
+    DELETED = "deleted"
+    DELETING = "deleting"
+    DOWN = "down"
+    ORDERING = "ordering"
+    PENDING = "pending"
+    REJECTED = "rejected"
+    REQUESTED = "requested"
+    UNKNOWN = "unknown"
+
+
+class PortEncryptionStatusType(str, Enum):
+    UP = "Encryption Up"
+    DOWN = "Encryption Down"
+
+
+class EncryptionModeType(str, Enum):
+    NO = "no_encrypt"
+    SHOULD = "should_encrypt"
+    MUST = "must_encrypt"
+
+
+class MacSecKeyStateType(str, Enum):
+    ASSOCIATING = "associating"
+    ASSOCIATED = "associated"
+    DISASSOCIATING = "disassociating"
+    DISASSOCIATED = "disassociated"
diff --git a/moto/directconnect/exceptions.py b/moto/directconnect/exceptions.py
new file mode 100644
index 000000000..ff418b037
--- /dev/null
+++ b/moto/directconnect/exceptions.py
@@ -0,0 +1,20 @@
+"""Exceptions raised by the directconnect service."""
+
+from moto.core.exceptions import JsonRESTError
+
+
+class DXConnectionError(JsonRESTError):
+    code = 400
+
+
+class ConnectionIdMissing(DXConnectionError):
+    def __init__(self) -> None:
+        super().__init__("ConnectionIdMissing", "The connection ID is missing.")
+
+
+class ConnectionNotFound(DXConnectionError):
+    def __init__(self, connnection_id: str, region: str) -> None:
+        super().__init__(
+            "ConnectionNotFound",
+            f"{connnection_id} does not match any connections in region {region}.",
+        )
diff --git a/moto/directconnect/models.py b/moto/directconnect/models.py
new file mode 100644
index 000000000..74b888a3b
--- /dev/null
+++ b/moto/directconnect/models.py
@@ -0,0 +1,184 @@
+"""DirectConnectBackend class with methods for supported APIs."""
+
+from dataclasses import dataclass, field
+from datetime import datetime
+from typing import Any, Dict, List, Optional
+
+from moto.core.base_backend import BackendDict, BaseBackend
+from moto.core.common_models import BaseModel
+
+from .enums import (
+    ConnectionStateType,
+    EncryptionModeType,
+    MacSecKeyStateType,
+    PortEncryptionStatusType,
+)
+from .exceptions import ConnectionIdMissing, ConnectionNotFound
+
+
+@dataclass
+class MacSecKey(BaseModel):
+    secret_arn: str
+    ckn: str
+    state: MacSecKeyStateType
+    start_on: str
+
+    def to_dict(self) -> Dict[str, str]:
+        return {
+            "secretARN": self.secret_arn,
+            "ckn": self.ckn,
+            "state": self.state,
+            "startOn": self.start_on,
+        }
+
+
+@dataclass
+class Connection(BaseModel):
+    aws_device_v2: str
+    aws_device: str
+    aws_logical_device_id: str
+    bandwidth: str
+    connection_name: str
+    connection_state: ConnectionStateType
+    encryption_mode: EncryptionModeType
+    has_logical_redundancy: bool
+    jumbo_frame_capable: bool
+    lag_id: Optional[str]
+    loa_issue_time: str
+    location: str
+    mac_sec_capable: Optional[bool]
+    mac_sec_keys: List[MacSecKey]
+    owner_account: str
+    partner_name: str
+    port_encryption_status: PortEncryptionStatusType
+    provider_name: Optional[str]
+    region: str
+    tags: Optional[List[Dict[str, str]]]
+    vlan: int
+    connection_id: str = field(default="", init=False)
+
+    def __post_init__(self) -> None:
+        if self.connection_id == "":
+            self.connection_id = f"dx-moto-{self.connection_name}-{datetime.now().strftime('%Y%m%d%H%M%S')}"
+
+    def to_dict(
+        self,
+    ) -> Dict[str, Any]:
+        return {
+            "awsDevice": self.aws_device,
+            "awsDeviceV2": self.aws_device_v2,
+            "awsLogicalDeviceId": self.aws_logical_device_id,
+            "bandwidth": self.bandwidth,
+            "connectionId": self.connection_id,
+            "connectionName": self.connection_name,
+            "connectionState": self.connection_state,
+            "encryptionMode": self.encryption_mode,
+            "hasLogicalRedundancy": self.has_logical_redundancy,
+            "jumboFrameCapable": self.jumbo_frame_capable,
+            "lagId": self.lag_id,
+            "loaIssueTime": self.loa_issue_time,
+            "location": self.location,
+            "macSecCapable": self.mac_sec_capable,
+            "macSecKeys": [key.to_dict() for key in self.mac_sec_keys],
+            "partnerName": self.partner_name,
+            "portEncryptionStatus": self.port_encryption_status,
+            "providerName": self.provider_name,
+            "region": self.region,
+            "tags": self.tags,
+            "vlan": self.vlan,
+        }
+
+
+class DirectConnectBackend(BaseBackend):
+    """Implementation of DirectConnect APIs."""
+
+    def __init__(self, region_name: str, account_id: str) -> None:
+        super().__init__(region_name, account_id)
+        self.connections: Dict[str, Connection] = {}
+
+    def describe_connections(self, connection_id: Optional[str]) -> List[Connection]:
+        if connection_id and connection_id not in self.connections:
+            raise ConnectionNotFound(connection_id, self.region_name)
+        if connection_id:
+            connection = self.connections.get(connection_id)
+            return [] if not connection else [connection]
+        return list(self.connections.values())
+
+    def create_connection(
+        self,
+        location: str,
+        bandwidth: str,
+        connection_name: str,
+        lag_id: Optional[str],
+        tags: Optional[List[Dict[str, str]]],
+        provider_name: Optional[str],
+        request_mac_sec: Optional[bool],
+    ) -> Connection:
+        encryption_mode = EncryptionModeType.NO
+        mac_sec_keys = []
+        if request_mac_sec:
+            encryption_mode = EncryptionModeType.MUST
+            mac_sec_keys = [
+                MacSecKey(
+                    secret_arn="mock_secret_arn",
+                    ckn="mock_ckn",
+                    state=MacSecKeyStateType.ASSOCIATED,
+                    start_on=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+                )
+            ]
+        connection = Connection(
+            aws_device_v2="mock_device_v2",
+            aws_device="mock_device",
+            aws_logical_device_id="mock_logical_device_id",
+            bandwidth=bandwidth,
+            connection_name=connection_name,
+            connection_state=ConnectionStateType.AVAILABLE,
+            encryption_mode=encryption_mode,
+            has_logical_redundancy=False,
+            jumbo_frame_capable=False,
+            lag_id=lag_id,
+            loa_issue_time=datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
+            location=location,
+            mac_sec_capable=request_mac_sec,
+            mac_sec_keys=mac_sec_keys,
+            owner_account=self.account_id,
+            partner_name="mock_partner",
+            port_encryption_status=PortEncryptionStatusType.DOWN,
+            provider_name=provider_name,
+            region=self.region_name,
+            tags=tags,
+            vlan=0,
+        )
+        self.connections[connection.connection_id] = connection
+        return connection
+
+    def delete_connection(self, connection_id: str) -> Connection:
+        if not connection_id:
+            raise ConnectionIdMissing()
+        connection = self.connections.get(connection_id)
+        if connection:
+            self.connections[
+                connection_id
+            ].connection_state = ConnectionStateType.DELETED
+            return connection
+        raise ConnectionNotFound(connection_id, self.region_name)
+
+    def update_connection(
+        self,
+        connection_id: str,
+        new_connection_name: Optional[str],
+        new_encryption_mode: Optional[EncryptionModeType],
+    ) -> Connection:
+        if not connection_id:
+            raise ConnectionIdMissing()
+        connection = self.connections.get(connection_id)
+        if connection:
+            if new_connection_name:
+                self.connections[connection_id].connection_name = new_connection_name
+            if new_encryption_mode:
+                self.connections[connection_id].encryption_mode = new_encryption_mode
+            return connection
+        raise ConnectionNotFound(connection_id, self.region_name)
+
+
+directconnect_backends = BackendDict(DirectConnectBackend, "directconnect")
diff --git a/moto/directconnect/responses.py b/moto/directconnect/responses.py
new file mode 100644
index 000000000..d01946e3c
--- /dev/null
+++ b/moto/directconnect/responses.py
@@ -0,0 +1,56 @@
+"""Handles incoming directconnect requests, invokes methods, returns responses."""
+
+import json
+
+from moto.core.responses import BaseResponse
+
+from .models import Connection, DirectConnectBackend, directconnect_backends
+
+
+class DirectConnectResponse(BaseResponse):
+    """Handler for DirectConnect requests and responses."""
+
+    def __init__(self) -> None:
+        super().__init__(service_name="directconnect")
+
+    @property
+    def directconnect_backend(self) -> DirectConnectBackend:
+        return directconnect_backends[self.current_account][self.region]
+
+    def describe_connections(self) -> str:
+        params = json.loads(self.body)
+        connections = self.directconnect_backend.describe_connections(
+            connection_id=params.get("connectionId"),
+        )
+        return json.dumps(
+            dict(connections=[connection.to_dict() for connection in connections])
+        )
+
+    def create_connection(self) -> str:
+        params = json.loads(self.body)
+        connection: Connection = self.directconnect_backend.create_connection(
+            location=params.get("location"),
+            bandwidth=params.get("bandwidth"),
+            connection_name=params.get("connectionName"),
+            lag_id=params.get("lagId"),
+            tags=params.get("tags"),
+            provider_name=params.get("providerName"),
+            request_mac_sec=params.get("requestMACSec"),
+        )
+        return json.dumps(connection.to_dict())
+
+    def delete_connection(self) -> str:
+        params = json.loads(self.body)
+        connection: Connection = self.directconnect_backend.delete_connection(
+            connection_id=params.get("connectionId"),
+        )
+        return json.dumps(connection.to_dict())
+
+    def update_connection(self) -> str:
+        params = json.loads(self.body)
+        connection: Connection = self.directconnect_backend.update_connection(
+            connection_id=params.get("connectionId"),
+            new_connection_name=params.get("connectionName"),
+            new_encryption_mode=params.get("encryptionMode"),
+        )
+        return json.dumps(connection.to_dict())
diff --git a/moto/directconnect/urls.py b/moto/directconnect/urls.py
new file mode 100644
index 000000000..800269df0
--- /dev/null
+++ b/moto/directconnect/urls.py
@@ -0,0 +1,11 @@
+"""directconnect base URL and path."""
+
+from .responses import DirectConnectResponse
+
+url_bases = [
+    r"https?://directconnect\.(.+)\.amazonaws\.com",
+]
+
+url_paths = {
+    "{0}/$": DirectConnectResponse.dispatch,
+}
diff --git a/moto/dynamodb/models/table.py b/moto/dynamodb/models/table.py
index 2696608fe..244fb2fa4 100644
--- a/moto/dynamodb/models/table.py
+++ b/moto/dynamodb/models/table.py
@@ -1026,39 +1026,33 @@ class Table(CloudFormationModel):
         If that is the case, we compare by the RK of the main table instead
         Related: https://github.com/getmoto/moto/issues/7761
         """
-        hash_comes_before = any(
-            item.attrs[hash_key_attr] != DynamoType(dct.get(hash_key_attr))  # type: ignore
-            for hash_key_attr in hash_key_attrs
-            if hash_key_attr in item.attrs
+        gsi_hash_key = hash_key_attrs[0] if len(hash_key_attrs) == 2 else None
+        table_hash_key = str(
+            hash_key_attrs[0] if gsi_hash_key is None else hash_key_attrs[1]
         )
-        if hash_comes_before:
-            # If hash keys are different, order immediately
-            return (
-                any(
-                    item.attrs[hash_key_attr] < DynamoType(dct.get(hash_key_attr))  # type: ignore
-                    for hash_key_attr in hash_key_attrs
-                    if hash_key_attr in item.attrs
-                )
-                == scan_index_forward
-            )
-        if not any(range_key_attrs):
-            # If hash keys match and no range key, items are identical
-            return True
-        # We know hash keys are equal, use range key as tiebreaker
-        if all(
-            item.attrs[attr] == DynamoType(dct.get(attr))  # type: ignore[arg-type]
-            for attr in range_key_attrs
-            if attr
-        ):
-            # If range keys (also) match, items are identical
-            return True
-        # Range keys are different, order accordingly
-        comes_before = any(
-            item.attrs[attr] < DynamoType(dct.get(attr))  # type: ignore[arg-type]
-            for attr in range_key_attrs
-            if attr
+        gsi_range_key = range_key_attrs[0] if len(range_key_attrs) == 2 else None
+        table_range_key = str(
+            range_key_attrs[0] if gsi_range_key is None else range_key_attrs[1]
         )
-        return comes_before == scan_index_forward
+        # Gets the GSI and table hash and range keys in the order to try sorting by
+        attrs_to_sort_by = [
+            gsi_hash_key,
+            gsi_range_key,
+            table_hash_key,
+            table_range_key,
+        ]
+        for attr in attrs_to_sort_by:
+            if (
+                attr is not None
+                and attr in item.attrs
+                and item.attrs[attr] != DynamoType(dct.get(attr))  # type: ignore
+            ):
+                return (
+                    (item.attrs[attr] < DynamoType(dct.get(attr)))  # type: ignore
+                    == scan_index_forward
+                )
+        # Keys were equal, items are identical
+        return True
 
     def _get_last_evaluated_key(
         self, last_result: Item, index_name: Optional[str]
diff --git a/moto/resourcegroupstaggingapi/models.py b/moto/resourcegroupstaggingapi/models.py
index 74c477978..b73ebcf7b 100644
--- a/moto/resourcegroupstaggingapi/models.py
+++ b/moto/resourcegroupstaggingapi/models.py
@@ -984,7 +984,7 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
         self, resource_arns: List[str], tags: Dict[str, str]
     ) -> Dict[str, Dict[str, Any]]:
         """
-        Only DynamoDB, Logs and RDS resources are currently supported
+        Only DynamoDB, Logs, RDS, and SageMaker resources are currently supported
         """
         missing_resources = []
         missing_error: Dict[str, Any] = {
@@ -1010,6 +1010,10 @@ class ResourceGroupsTaggingAPIBackend(BaseBackend):
                 self.dynamodb_backend.tag_resource(
                     arn, TaggingService.convert_dict_to_tags_input(tags)
                 )
+            elif arn.startswith(f"arn:{get_partition(self.region_name)}:sagemaker:"):
+                self.sagemaker_backend.add_tags(
+                    arn, TaggingService.convert_dict_to_tags_input(tags)
+                )
             else:
                 missing_resources.append(arn)
         return {arn: missing_error for arn in missing_resources}


Test patch:
diff --git a/tests/test_directconnect/__init__.py b/tests/test_directconnect/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/tests/test_directconnect/test_directconnect.py b/tests/test_directconnect/test_directconnect.py
new file mode 100644
index 000000000..155b8099f
--- /dev/null
+++ b/tests/test_directconnect/test_directconnect.py
@@ -0,0 +1,90 @@
+"""Unit tests for directconnect-supported APIs."""
+
+import time
+
+import boto3
+import pytest
+
+from moto import mock_aws
+
+
+@pytest.fixture(name="client")
+def fixture_dx_client():
+    with mock_aws():
+        yield boto3.client("directconnect", region_name="us-east-1")
+
+
+def test_create_connection(client):
+    connection = client.create_connection(
+        location="EqDC2", bandwidth="10Gbps", connectionName="TestConnection"
+    )
+    assert connection["connectionId"].startswith("dx-moto")
+    assert connection["connectionState"] == "available"
+
+
+@mock_aws
+def test_describe_connections(client):
+    client.create_connection(
+        location="EqDC2",
+        bandwidth="10Gbps",
+        connectionName="TestConnection1",
+        requestMACSec=False,
+    )
+    time.sleep(1)
+    client.create_connection(
+        location="EqDC2",
+        bandwidth="10Gbps",
+        connectionName="TestConnection2",
+        requestMACSec=True,
+    )
+    time.sleep(1)
+    resp = client.describe_connections()
+    connections = resp["connections"]
+    assert len(connections) == 2
+    assert not connections[0]["macSecCapable"]
+    assert connections[1]["macSecCapable"]
+    assert len(connections[0]["macSecKeys"]) == 0
+    assert len(connections[1]["macSecKeys"]) == 1
+    assert connections[0]["encryptionMode"] == "no_encrypt"
+    assert connections[1]["encryptionMode"] == "must_encrypt"
+    resp = client.describe_connections(connectionId=connections[0]["connectionId"])
+    assert len(resp["connections"]) == 1
+
+
+@mock_aws
+def test_delete_connection(client):
+    connection = client.create_connection(
+        location="EqDC2", bandwidth="10Gbps", connectionName="TestConnection"
+    )
+    connection = client.delete_connection(connectionId=connection["connectionId"])
+    assert connection["connectionState"] == "deleted"
+
+
+@mock_aws
+def test_update_connection(client):
+    client.create_connection(
+        location="EqDC2",
+        bandwidth="10Gbps",
+        connectionName="TestConnection1",
+    )
+    time.sleep(1)
+    client.create_connection(
+        location="EqDC2",
+        bandwidth="10Gbps",
+        connectionName="TestConnection2",
+        requestMACSec=True,
+    )
+    time.sleep(1)
+    resp = client.describe_connections()
+    connection1_id = resp["connections"][0]["connectionId"]
+    connection2_id = resp["connections"][1]["connectionId"]
+    connection = client.update_connection(
+        connectionId=connection1_id,
+        connectionName="NewConnectionName",
+    )
+    assert connection["connectionName"] == "NewConnectionName"
+    connection = client.update_connection(
+        connectionId=connection2_id,
+        encryptionMode="should_encrypt",
+    )
+    assert connection["encryptionMode"] == "should_encrypt"
diff --git a/tests/test_dynamodb/test_dynamodb_query.py b/tests/test_dynamodb/test_dynamodb_query.py
index 4770efd3e..c5619a3ad 100644
--- a/tests/test_dynamodb/test_dynamodb_query.py
+++ b/tests/test_dynamodb/test_dynamodb_query.py
@@ -326,6 +326,63 @@ def test_query_gsi_pagination_with_string_gsi_range(table_name=None):
     assert subjects == set(range(10))
 
 
+@mock_aws
+def test_query_gsi_pagination_with_opposite_pk_order():
+    dynamodb = boto3.resource("dynamodb", region_name="us-east-1")
+
+    # Create the DynamoDB table.
+    dynamodb.create_table(
+        TableName="users",
+        KeySchema=[
+            {"AttributeName": "pk", "KeyType": "HASH"},
+        ],
+        AttributeDefinitions=[
+            {"AttributeName": "pk", "AttributeType": "S"},
+            {"AttributeName": "gsi_hash", "AttributeType": "S"},
+            {"AttributeName": "gsi_sort", "AttributeType": "S"},
+        ],
+        ProvisionedThroughput={"ReadCapacityUnits": 5, "WriteCapacityUnits": 5},
+        GlobalSecondaryIndexes=[
+            {
+                "IndexName": "gsi",
+                "KeySchema": [
+                    {"AttributeName": "gsi_hash", "KeyType": "HASH"},
+                    {"AttributeName": "gsi_sort", "KeyType": "RANGE"},
+                ],
+                "Projection": {"ProjectionType": "ALL"},
+            }
+        ],
+    )
+    table = dynamodb.Table("users")
+
+    table.put_item(Item={"pk": "b", "gsi_hash": "a", "gsi_sort": "a"})
+    table.put_item(Item={"pk": "a", "gsi_hash": "a", "gsi_sort": "b"})
+
+    page1 = table.query(
+        KeyConditionExpression=Key("gsi_hash").eq("a"),
+        IndexName="gsi",
+        Limit=1,
+    )
+    assert page1["Count"] == 1
+    assert page1["ScannedCount"] == 1
+    assert len(page1["Items"]) == 1
+
+    page2 = table.query(
+        KeyConditionExpression=Key("gsi_hash").eq("a"),
+        IndexName="gsi",
+        Limit=1,
+        ExclusiveStartKey=page1["LastEvaluatedKey"],
+    )
+    assert page2["Count"] == 1
+    assert page2["ScannedCount"] == 1
+    assert len(page2["Items"]) == 1
+    assert "LastEvaluatedKey" not in page2
+
+    results = page1["Items"] + page2["Items"]
+    subjects = [(r["pk"]) for r in results]
+    assert subjects == ["b", "a"]
+
+
 @pytest.mark.aws_verified
 @dynamodb_aws_verified(add_range=True, add_gsi_range=True)
 def test_query_gsi_pagination_with_string_gsi_range_and_empty_gsi_pk(table_name=None):
diff --git a/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py b/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py
index a553f232a..9a43a3d33 100644
--- a/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py
+++ b/tests/test_resourcegroupstaggingapi/test_resourcegroupstaggingapi.py
@@ -1033,6 +1033,40 @@ def test_get_resources_sagemaker_cluster():
     ][0]["Tags"]
 
 
+@mock_aws
+def test_tag_resources_sagemaker():
+    sagemaker = boto3.client("sagemaker", region_name="us-east-1")
+    rtapi = boto3.client("resourcegroupstaggingapi", region_name="us-east-1")
+    resp = sagemaker.create_cluster(
+        ClusterName="testcluster",
+        InstanceGroups=[
+            {
+                "InstanceCount": 10,
+                "InstanceGroupName": "testgroup",
+                "InstanceType": "ml.p4d.24xlarge",
+                "LifeCycleConfig": {
+                    "SourceS3Uri": "s3://sagemaker-lifecycleconfig",
+                    "OnCreate": "filename",
+                },
+                "ExecutionRole": "arn:aws:iam::123456789012:role/service-role/AmazonSageMaker-TestExecutionRole",
+                "ThreadsPerCore": 2,
+            },
+        ],
+        Tags=[
+            {"Key": "sagemakerkey", "Value": "sagemakervalue"},
+        ],
+    )
+    rtapi.tag_resources(
+        ResourceARNList=[resp["ClusterArn"]], Tags={"key1": "k", "key2": "v"}
+    )
+
+    assert sagemaker.list_tags(ResourceArn=resp["ClusterArn"])["Tags"] == [
+        {"Key": "sagemakerkey", "Value": "sagemakervalue"},
+        {"Key": "key1", "Value": "k"},
+        {"Key": "key2", "Value": "v"},
+    ]
+
+
 @mock_aws
 def test_get_resources_efs():
     client = boto3.client("resourcegroupstaggingapi", region_name="us-east-1")