Repository: getmoto/moto
Pull request #8907 (2025-05-15 21:10:09)

[RDS] Add shard group endpoint support
This PR addresses #8906  

Adding support for:
- [create_db_shard_group](https://docs.getmoto.org/en/latest/docs/services/rds.html#:~:text=create_db_shard_group)
- [describe_db_shard_groups](https://docs.getmoto.org/en/latest/docs/services/rds.html#:~:text=describe_db_shard_groups)

Thank you @leslieetubo for helping refactor!

bpandola: @LeekSwan You still working on this?
LeekSwan: Yes I am!

Patch:
diff --git a/moto/rds/exceptions.py b/moto/rds/exceptions.py
index fc27a352e..c946abfdd 100644
--- a/moto/rds/exceptions.py
+++ b/moto/rds/exceptions.py
@@ -37,6 +37,14 @@ class DBSecurityGroupNotFoundError(RDSClientError):
         )
 
 
+class DBShardGroupAlreadyExistsError(RDSClientError):
+    def __init__(self, shard_group_identifier: str):
+        super().__init__(
+            "DBShardGroupAlreadyExistsFault",
+            f"DB Shard Group {shard_group_identifier} already exists.",
+        )
+
+
 class DBSubnetGroupNotFoundError(RDSClientError):
     def __init__(self, subnet_group_name: str):
         super().__init__(
@@ -149,6 +157,14 @@ class DBSnapshotAlreadyExistsError(RDSClientError):
         )
 
 
+class DBShardGroupNotFoundFault(RDSClientError):
+    def __init__(self, db_shard_group_identifier: str):
+        super().__init__(
+            "DBShardGroupNotFoundFault",
+            f"DBShardGroup {db_shard_group_identifier} not found.",
+        )
+
+
 class InvalidParameterValue(RDSClientError):
     def __init__(self, message: str):
         super().__init__("InvalidParameterValue", message)
diff --git a/moto/rds/models.py b/moto/rds/models.py
index 2f43f8ff2..f19612423 100644
--- a/moto/rds/models.py
+++ b/moto/rds/models.py
@@ -53,6 +53,8 @@ from .exceptions import (
     DBProxyNotFoundFault,
     DBProxyQuotaExceededFault,
     DBSecurityGroupNotFoundError,
+    DBShardGroupAlreadyExistsError,
+    DBShardGroupNotFoundFault,
     DBSnapshotAlreadyExistsError,
     DBSnapshotNotFoundFault,
     DBSubnetGroupNotFoundError,
@@ -2208,6 +2210,47 @@ class DBInstanceAutomatedBackup:
         return status
 
 
+class DBShardGroup(RDSBaseModel):
+    resource_type = "shard-group"
+
+    SUPPORTED_FILTERS = {
+        "db-shard-group-id": FilterDef(
+            ["db_shard_group_identifier"],
+            "DB Shard Group Identifier",
+        ),
+        "db-cluster-id": FilterDef(
+            ["db_cluster_identifier"],
+            "DB Cluster Identifier",
+        ),
+    }
+
+    def __init__(
+        self,
+        backend: RDSBackend,
+        db_shard_group_identifier: str,
+        db_cluster_identifier: str,
+        max_acu: float,
+        compute_redundancy: Optional[int] = None,
+        min_acu: Optional[float] = None,
+        publicly_accessible: Optional[bool] = None,
+        tags: Optional[List[Dict[str, str]]] = None,
+    ):
+        super().__init__(backend)
+        self.db_shard_group_identifier = db_shard_group_identifier
+        self.db_cluster_identifier = db_cluster_identifier
+        self.compute_redundancy = compute_redundancy
+        self.max_acu = max_acu
+        self.min_acu = min_acu
+        self.status = "available"
+        self.db_shard_group_resource_id = (
+            f"shard-group-{self.db_shard_group_identifier}"
+        )
+        self.endpoint = f"{self.db_cluster_identifier}.{self.region}.rds.amazonaws.com"
+        self.db_shard_group_arn = f"arn:aws:rds:{self.region}:{self.account_id}:shard-group:{self.db_shard_group_identifier}"
+        self.publicly_accessible = publicly_accessible
+        self.tags = tags or []
+
+
 class RDSBackend(BaseBackend):
     def __init__(self, region_name: str, account_id: str):
         super().__init__(region_name, account_id)
@@ -2232,6 +2275,7 @@ class RDSBackend(BaseBackend):
         ] = CaseInsensitiveDict()
         self.option_groups: MutableMapping[str, OptionGroup] = CaseInsensitiveDict()
         self.security_groups: Dict[str, DBSecurityGroup] = {}
+        self.shard_groups: Dict[str, DBShardGroup] = {}
         self.subnet_groups: MutableMapping[str, DBSubnetGroup] = CaseInsensitiveDict()
         self._db_cluster_options: Optional[List[Dict[str, Any]]] = None
         self.db_proxies: Dict[str, DBProxy] = OrderedDict()
@@ -2244,6 +2288,7 @@ class RDSBackend(BaseBackend):
             DBParameterGroup: self.db_parameter_groups,
             DBProxy: self.db_proxies,
             DBSecurityGroup: self.security_groups,
+            DBShardGroup: self.shard_groups,
             DBSnapshot: self.database_snapshots,
             DBSubnetGroup: self.subnet_groups,
             EventSubscription: self.event_subscriptions,
@@ -4073,6 +4118,50 @@ class RDSBackend(BaseBackend):
         bg_deployment.deletion_time = utcnow()
         return bg_deployment
 
+    def create_db_shard_group(self, kwargs: Dict[str, Any]) -> DBShardGroup:
+        db_shard_group = DBShardGroup(self, **kwargs)
+
+        # Validate
+        db_shard_group_identifier = kwargs["db_shard_group_identifier"]
+        db_cluster_identifier = kwargs["db_cluster_identifier"]
+        compute_redundancy = kwargs.get("compute_redundancy")
+        max_acu = kwargs["max_acu"]
+        min_acu = kwargs.get("min_acu")
+
+        if db_shard_group_identifier in self.shard_groups:
+            raise DBShardGroupAlreadyExistsError(db_shard_group_identifier)
+
+        if db_cluster_identifier not in self.clusters:
+            raise DBClusterNotFoundError(db_cluster_identifier)
+
+        if compute_redundancy not in (None, 0, 1, 2):
+            raise InvalidParameterValue(
+                f"Invalid ComputeRedundancy value: '{compute_redundancy}'. "
+                "Valid values are 0 (no standby), 1 (1 standby AZ), 2 (2 standby AZs)."
+            )
+
+        if "min_acu" in kwargs and min_acu >= max_acu:
+            raise InvalidParameterValue("min_acu cannot be larger than max_acu")
+
+        self.shard_groups[db_shard_group.db_shard_group_identifier] = db_shard_group
+        return db_shard_group
+
+    def describe_db_shard_groups(
+        self,
+        db_shard_group_identifier: Optional[str],
+        filters: Optional[Dict[str, List[str]]],
+    ) -> List[DBShardGroup]:
+        shard_groups = self.shard_groups
+        if db_shard_group_identifier:
+            filters = merge_filters(
+                filters, {"db-shard-group-id": [db_shard_group_identifier]}
+            )
+        if filters:
+            shard_groups = self._filter_resources(shard_groups, filters, DBShardGroup)
+        if db_shard_group_identifier and not shard_groups:
+            raise DBShardGroupNotFoundFault(db_shard_group_identifier)
+        return list(shard_groups.values())
+
     def _is_cluster(self, arn: str) -> bool:
         return arn.split(":")[-2] == "cluster"
 
diff --git a/moto/rds/responses.py b/moto/rds/responses.py
index af70c00a0..6dff3dc1f 100644
--- a/moto/rds/responses.py
+++ b/moto/rds/responses.py
@@ -129,6 +129,21 @@ class RDSResponse(BaseResponse):
         result = {"DBSnapshot": snapshot}
         return ActionResult(result)
 
+    def create_db_shard_group(self) -> ActionResult:
+        kwargs = self.parameters
+        db_shard_group = self.backend.create_db_shard_group(kwargs)
+        return ActionResult(db_shard_group)
+
+    def describe_db_shard_groups(self) -> ActionResult:
+        db_shard_group_identifier = self.parameters.get("DBShardGroupIdentifier", None)
+        filters = self.parameters.get("Filters", [])
+        filter_dict = {f["Name"]: f["Values"] for f in filters}
+        shard_groups = self.backend.describe_db_shard_groups(
+            db_shard_group_identifier, filter_dict
+        )
+        result = {"DBShardGroups": shard_groups, "Marker": None}
+        return ActionResult(result)
+
     def copy_db_snapshot(self) -> ActionResult:
         target_snapshot_identifier = self.parameters.get("TargetDBSnapshotIdentifier")
         self.backend.validate_db_snapshot_identifier(


Test patch:
diff --git a/tests/test_rds/test_db_shard_group.py b/tests/test_rds/test_db_shard_group.py
new file mode 100644
index 000000000..49dac9673
--- /dev/null
+++ b/tests/test_rds/test_db_shard_group.py
@@ -0,0 +1,210 @@
+import boto3
+import pytest
+from botocore.exceptions import ClientError
+
+from moto import mock_aws
+from moto.core import DEFAULT_ACCOUNT_ID
+
+
+@pytest.fixture(name="client")
+@mock_aws
+def get_rds_client():
+    return boto3.client("rds", region_name="us-east-2")
+
+
+@mock_aws
+def test_create_db_shard_group(client):
+    client.create_db_cluster(
+        DBClusterIdentifier="test_db_cluster_identifier",
+        Engine="aurora-postgresql",
+        MasterUsername="admin",
+        MasterUserPassword="root-password",
+    )
+    shard_group = client.create_db_shard_group(
+        DBShardGroupIdentifier="shardgroup1",
+        DBClusterIdentifier="test_db_cluster_identifier",
+        MaxACU=100,
+        MinACU=50,
+        PubliclyAccessible=True,
+        Tags=[{"Key": "Environment", "Value": "Test"}],
+    )
+    assert shard_group["DBShardGroupIdentifier"] == "shardgroup1"
+    assert shard_group["DBClusterIdentifier"] == "test_db_cluster_identifier"
+    assert (
+        shard_group["DBShardGroupArn"]
+        == f"arn:aws:rds:us-east-2:{DEFAULT_ACCOUNT_ID}:shard-group:shardgroup1"
+    )
+
+
+@mock_aws
+def test_create_db_shard_group_duplicate(client):
+    client.create_db_cluster(
+        DBClusterIdentifier="test_db_cluster_identifier",
+        Engine="aurora-postgresql",
+        MasterUsername="admin",
+        MasterUserPassword="root-password",
+    )
+
+    client.create_db_shard_group(
+        DBShardGroupIdentifier="shardgroup1",
+        DBClusterIdentifier="test_db_cluster_identifier",
+        MaxACU=100,
+        MinACU=50,
+        PubliclyAccessible=True,
+        Tags=[{"Key": "Environment", "Value": "Test"}],
+    )
+
+    with pytest.raises(ClientError) as ex:
+        client.create_db_shard_group(
+            DBShardGroupIdentifier="shardgroup1",
+            DBClusterIdentifier="test_db_cluster_identifier",
+            MaxACU=100,
+            MinACU=50,
+            PubliclyAccessible=True,
+            Tags=[{"Key": "Environment", "Value": "Test"}],
+        )
+    err = ex.value.response["Error"]
+    assert err["Code"] == "DBShardGroupAlreadyExists"
+    assert err["Message"] == "DB Shard Group shardgroup1 already exists."
+
+
+@mock_aws
+def test_shard_group_cluster_not_found(client):
+    with pytest.raises(ClientError) as ex:
+        client.create_db_shard_group(
+            DBShardGroupIdentifier="shardgroup1",
+            DBClusterIdentifier="test_db_cluster_identifier",
+            MaxACU=100,
+            MinACU=50,
+            PubliclyAccessible=True,
+            Tags=[{"Key": "Environment", "Value": "Test"}],
+        )
+    err = ex.value.response["Error"]
+    assert err["Code"] == "DBClusterNotFoundFault"
+    assert err["Message"] == "DBCluster test_db_cluster_identifier not found."
+
+
+@mock_aws
+def test_shard_group_cluster_invalid_compute_redundancy(client):
+    client.create_db_cluster(
+        DBClusterIdentifier="test_db_cluster_identifier",
+        Engine="aurora-postgresql",
+        MasterUsername="admin",
+        MasterUserPassword="root-password",
+    )
+    with pytest.raises(ClientError) as ex:
+        client.create_db_shard_group(
+            DBShardGroupIdentifier="shardgroup1",
+            DBClusterIdentifier="test_db_cluster_identifier",
+            ComputeRedundancy=3,
+            MaxACU=100,
+            MinACU=50,
+            PubliclyAccessible=True,
+            Tags=[{"Key": "Environment", "Value": "Test"}],
+        )
+    err = ex.value.response["Error"]
+    assert err["Code"] == "InvalidParameterValue"
+    assert (
+        err["Message"]
+        == "Invalid ComputeRedundancy value: '3'. Valid values are 0 (no standby), 1 (1 standby AZ), 2 (2 standby AZs)."
+    )
+
+
+@mock_aws
+def test_shard_group_cluster_invalid_max_min_acu(client):
+    client.create_db_cluster(
+        DBClusterIdentifier="test_db_cluster_identifier",
+        Engine="aurora-postgresql",
+        MasterUsername="admin",
+        MasterUserPassword="root-password",
+    )
+    with pytest.raises(ClientError) as ex:
+        client.create_db_shard_group(
+            DBShardGroupIdentifier="shardgroup1",
+            DBClusterIdentifier="test_db_cluster_identifier",
+            ComputeRedundancy=1,
+            MaxACU=0,
+            MinACU=100,
+            PubliclyAccessible=True,
+            Tags=[{"Key": "Environment", "Value": "Test"}],
+        )
+    err = ex.value.response["Error"]
+    assert err["Code"] == "InvalidParameterValue"
+    assert err["Message"] == "min_acu cannot be larger than max_acu"
+
+
+@mock_aws
+def test_describe_db_shard_group_initial(client):
+    shard_groups = client.describe_db_shard_groups()
+    assert len(shard_groups["DBShardGroups"]) == 0
+
+
+@mock_aws
+def test_describe_db_shard_group_non_existent(client):
+    shard_groups = client.describe_db_shard_groups()
+    assert len(shard_groups["DBShardGroups"]) == 0
+    with pytest.raises(ClientError) as ex:
+        client.describe_db_shard_groups(DBShardGroupIdentifier="shardgroup1")
+    err = ex.value.response["Error"]
+    assert err["Code"] == "DBShardGroupNotFound"
+    assert err["Message"] == "DBShardGroup shardgroup1 not found."
+
+
+@mock_aws
+def test_describe_db_shard_group_after_creation(client):
+    client.create_db_cluster(
+        DBClusterIdentifier="test_db_cluster_identifier",
+        Engine="aurora-postgresql",
+        MasterUsername="admin",
+        MasterUserPassword="root-password",
+    )
+
+    client.create_db_shard_group(
+        DBShardGroupIdentifier="shardgroup1",
+        DBClusterIdentifier="test_db_cluster_identifier",
+        MaxACU=100,
+        MinACU=50,
+        PubliclyAccessible=True,
+        Tags=[{"Key": "Environment", "Value": "Test"}],
+    )
+
+    shard_groups = client.describe_db_shard_groups(
+        DBShardGroupIdentifier="shardgroup1"
+    )["DBShardGroups"]
+    assert len(shard_groups) == 1
+    assert shard_groups[0]["DBShardGroupIdentifier"] == "shardgroup1"
+    assert shard_groups[0]["DBClusterIdentifier"] == "test_db_cluster_identifier"
+    assert (
+        shard_groups[0]["DBShardGroupArn"]
+        == f"arn:aws:rds:us-east-2:{DEFAULT_ACCOUNT_ID}:shard-group:shardgroup1"
+    )
+
+
+@mock_aws
+def test_describe_db_shard_group_filter_by_cluster_id(client):
+    client.create_db_cluster(
+        DBClusterIdentifier="test_db_cluster_identifier",
+        Engine="aurora-postgresql",
+        MasterUsername="admin",
+        MasterUserPassword="root-password",
+    )
+    client.create_db_shard_group(
+        DBShardGroupIdentifier="shardgroup1",
+        DBClusterIdentifier="test_db_cluster_identifier",
+        MaxACU=100,
+        MinACU=50,
+        PubliclyAccessible=True,
+        Tags=[{"Key": "Environment", "Value": "Test"}],
+    )
+
+    shard_groups = client.describe_db_shard_groups(
+        DBShardGroupIdentifier="shardgroup1"
+    )["DBShardGroups"]
+
+    assert len(shard_groups) == 1
+    assert shard_groups[0]["DBShardGroupIdentifier"] == "shardgroup1"
+    assert shard_groups[0]["DBClusterIdentifier"] == "test_db_cluster_identifier"
+    assert (
+        shard_groups[0]["DBShardGroupArn"]
+        == f"arn:aws:rds:us-east-2:{DEFAULT_ACCOUNT_ID}:shard-group:shardgroup1"
+    )