From 4795888fda83269a5ba978b66397f41e7b88ac03 Mon Sep 17 00:00:00 2001 From: Bert Blommers Date: Sun, 10 Oct 2021 19:18:19 +0000 Subject: [PATCH] RDS - Cluster-methods + restore_db_from_snapshot (#4247) --- moto/rds2/exceptions.py | 13 ++ moto/rds2/models.py | 175 +++++++++++++++ moto/rds2/responses.py | 118 ++++++++++ tests/test_rds2/test_rds2.py | 82 +++++++ tests/test_rds2/test_rds2_clusters.py | 305 ++++++++++++++++++++++++++ 5 files changed, 693 insertions(+) create mode 100644 tests/test_rds2/test_rds2_clusters.py diff --git a/moto/rds2/exceptions.py b/moto/rds2/exceptions.py index 8866b9bfd..52e5b91bf 100644 --- a/moto/rds2/exceptions.py +++ b/moto/rds2/exceptions.py @@ -120,3 +120,16 @@ class InvalidParameterCombination(RDSClientError): super(InvalidParameterCombination, self).__init__( "InvalidParameterCombination", message ) + + +class InvalidDBClusterStateFault(RDSClientError): + def __init__(self, message): + super().__init__("InvalidDBClusterStateFault", message) + + +class DBClusterNotFoundError(RDSClientError): + def __init__(self, cluster_identifier): + super(DBClusterNotFoundError, self).__init__( + "DBClusterNotFoundFault", + "DBCluster {} not found.".format(cluster_identifier), + ) diff --git a/moto/rds2/models.py b/moto/rds2/models.py index d4ffaca26..f77323ca9 100644 --- a/moto/rds2/models.py +++ b/moto/rds2/models.py @@ -3,6 +3,8 @@ from __future__ import unicode_literals import copy import datetime import os +import random +import string from collections import defaultdict from boto3 import Session @@ -15,6 +17,7 @@ from moto.core.utils import iso_8601_datetime_with_milliseconds from moto.ec2.models import ec2_backends from .exceptions import ( RDSClientError, + DBClusterNotFoundError, DBInstanceNotFoundError, DBSnapshotNotFoundError, DBSecurityGroupNotFoundError, @@ -27,10 +30,122 @@ from .exceptions import ( DBSnapshotAlreadyExistsError, InvalidParameterValue, InvalidParameterCombination, + InvalidDBClusterStateFault, ) from .utils import FilterDef, apply_filter, merge_filters, validate_filters +class Cluster: + def __init__(self, **kwargs): + self.db_name = kwargs.get("db_name") + self.db_cluster_identifier = kwargs.get("db_cluster_identifier") + self.engine = kwargs.get("engine") + self.engine_version = kwargs.get("engine_version") + if not self.engine_version: + # Set default + self.engine_version = "5.6.mysql_aurora.1.22.5" # TODO: depends on engine + self.engine_mode = kwargs.get("engine_mode") or "provisioned" + self.status = "active" + self.region = kwargs.get("region") + self.cluster_create_time = iso_8601_datetime_with_milliseconds( + datetime.datetime.now() + ) + self.master_username = kwargs.get("master_username") + if not self.master_username: + raise InvalidParameterValue( + "The parameter MasterUsername must be provided and must not be blank." + ) + self.master_user_password = kwargs.get("master_user_password") + if not self.master_user_password: + raise InvalidParameterValue( + "The parameter MasterUserPassword must be provided and must not be blank." + ) + if len(self.master_user_password) < 8: + raise InvalidParameterValue( + "The parameter MasterUserPassword is not a valid password because it is shorter than 8 characters." + ) + self.availability_zones = kwargs.get("availability_zones") + if not self.availability_zones: + self.availability_zones = [ + f"{self.region}a", + f"{self.region}b", + f"{self.region}c", + ] + self.parameter_group = kwargs.get("parameter_group") or "default.aurora5.6" + self.subnet_group = "default" + self.status = "creating" + self.url_identifier = "".join( + random.choice(string.ascii_lowercase + string.digits) for _ in range(12) + ) + self.endpoint = f"{self.db_cluster_identifier}.cluster-{self.url_identifier}.{self.region}.rds.amazonaws.com" + self.reader_endpoint = f"{self.db_cluster_identifier}.cluster-ro-{self.url_identifier}.{self.region}.rds.amazonaws.com" + self.port = kwargs.get("port") or 3306 + self.preferred_backup_window = "01:37-02:07" + self.preferred_maintenance_window = "wed:02:40-wed:03:10" + # This should default to the default security group + self.vpc_security_groups = [] + self.hosted_zone_id = "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(14) + ) + self.resource_id = "cluster-" + "".join( + random.choice(string.ascii_uppercase + string.digits) for _ in range(26) + ) + self.arn = f"arn:aws:rds:{self.region}:{ACCOUNT_ID}:cluster:{self.db_cluster_identifier}" + + def to_xml(self): + template = Template( + """ + 1 + + {% for zone in cluster.availability_zones %} + {{ zone }} + {% endfor %} + + 1 + {{ cluster.status }} + {% if cluster.db_name %}{{ cluster.db_name }}{% endif %} + {{ cluster.db_cluster_identifier }} + {{ cluster.parameter_group }} + {{ cluster.subnet_group }} + {{ cluster.cluster_create_time }} + {{ cluster.engine }} + {{ cluster.status }} + {{ cluster.endpoint }} + {{ cluster.reader_endpoint }} + false + {{ cluster.engine_version }} + {{ cluster.port }} + {{ cluster.master_username }} + {{ cluster.preferred_backup_window }} + {{ cluster.preferred_maintenance_window }} + + + + {% for id in cluster.vpc_security_groups %} + + {{ id }} + active + + {% endfor %} + + {{ cluster.hosted_zone_id }} + false + {{ cluster.resource_id }} + {{ cluster.arn }} + + false + {{ cluster.engine_mode }} + false + false + false + false + + + """ + ) + return template.render(cluster=self) + + class Database(CloudFormationModel): SUPPORTED_FILTERS = { @@ -132,6 +247,7 @@ class Database(CloudFormationModel): ) self.license_model = kwargs.get("license_model", "general-public-license") self.option_group_name = kwargs.get("option_group_name", None) + self.option_group_supplied = self.option_group_name is not None if ( self.option_group_name and self.option_group_name not in rds2_backends[self.region].option_groups @@ -834,6 +950,7 @@ class RDS2Backend(BaseBackend): self.arn_regex = re_compile( r"^arn:aws:rds:.*:[0-9]*:(db|es|og|pg|ri|secgrp|snapshot|subgrp):.*$" ) + self.clusters = OrderedDict() self.databases = OrderedDict() self.snapshots = OrderedDict() self.db_parameter_groups = {} @@ -946,6 +1063,23 @@ class RDS2Backend(BaseBackend): database = self.describe_databases(db_instance_identifier)[0] return database + def restore_db_instance_from_db_snapshot(self, from_snapshot_id, overrides): + snapshot = self.describe_snapshots( + db_instance_identifier=None, db_snapshot_identifier=from_snapshot_id + )[0] + original_database = snapshot.database + new_instance_props = copy.deepcopy(original_database.__dict__) + if not original_database.option_group_supplied: + # If the option group is not supplied originally, the 'option_group_name' will receive a default value + # Force this reconstruction, and prevent any validation on the default value + del new_instance_props["option_group_name"] + + for key, value in overrides.items(): + if value: + new_instance_props[key] = value + + return self.create_database(new_instance_props) + def stop_database(self, db_instance_identifier, db_snapshot_identifier=None): database = self.describe_databases(db_instance_identifier)[0] # todo: certain rds types not allowed to be stopped at this time. @@ -1283,6 +1417,47 @@ class RDS2Backend(BaseBackend): return db_parameter_group + def create_db_cluster(self, kwargs): + cluster_id = kwargs["db_cluster_identifier"] + cluster = Cluster(**kwargs) + self.clusters[cluster_id] = cluster + initial_state = copy.deepcopy(cluster) # Return status=creating + cluster.status = "available" # Already set the final status in the background + return initial_state + + def describe_db_clusters(self, cluster_identifier): + if cluster_identifier: + return [self.clusters[cluster_identifier]] + return self.clusters.values() + + def delete_db_cluster(self, cluster_identifier): + return self.clusters.pop(cluster_identifier) + + def start_db_cluster(self, cluster_identifier): + if cluster_identifier not in self.clusters: + raise DBClusterNotFoundError(cluster_identifier) + cluster = self.clusters[cluster_identifier] + if cluster.status != "stopped": + raise InvalidDBClusterStateFault( + "DbCluster cluster-id is not in stopped state." + ) + temp_state = copy.deepcopy(cluster) + temp_state.status = "started" + cluster.status = "available" # This is the final status - already setting it in the background + return temp_state + + def stop_db_cluster(self, cluster_identifier): + if cluster_identifier not in self.clusters: + raise DBClusterNotFoundError(cluster_identifier) + cluster = self.clusters[cluster_identifier] + if cluster.status not in ["available"]: + raise InvalidDBClusterStateFault( + "DbCluster cluster-id is not in available state." + ) + previous_state = copy.deepcopy(cluster) + cluster.status = "stopped" + return previous_state + def list_tags_for_resource(self, arn): if self.arn_regex.match(arn): arn_breakdown = arn.split(":") diff --git a/moto/rds2/responses.py b/moto/rds2/responses.py index ed1a9a628..fd16da643 100644 --- a/moto/rds2/responses.py +++ b/moto/rds2/responses.py @@ -87,6 +87,23 @@ class RDS2Response(BaseResponse): "tags": self.unpack_complex_list_params("Tags.Tag", ("Key", "Value")), } + def _get_db_cluster_kwargs(self): + return { + "availability_zones": self._get_multi_param( + "AvailabilityZones.AvailabilityZone" + ), + "db_name": self._get_param("DatabaseName"), + "db_cluster_identifier": self._get_param("DBClusterIdentifier"), + "engine": self._get_param("Engine"), + "engine_version": self._get_param("EngineVersion"), + "engine_mode": self._get_param("EngineMode"), + "master_username": self._get_param("MasterUsername"), + "master_user_password": self._get_param("MasterUserPassword"), + "port": self._get_param("Port"), + "parameter_group": self._get_param("DBClusterParameterGroup"), + "region": self.region, + } + def unpack_complex_list_params(self, label, names): unpacked_list = list() count = 1 @@ -195,6 +212,15 @@ class RDS2Response(BaseResponse): template = self.response_template(DELETE_SNAPSHOT_TEMPLATE) return template.render(snapshot=snapshot) + def restore_db_instance_from_db_snapshot(self): + db_snapshot_identifier = self._get_param("DBSnapshotIdentifier") + db_kwargs = self._get_db_kwargs() + new_instance = self.backend.restore_db_instance_from_db_snapshot( + db_snapshot_identifier, db_kwargs + ) + template = self.response_template(RESTORE_INSTANCE_FROM_SNAPSHOT_TEMPLATE) + return template.render(database=new_instance) + def list_tags_for_resource(self): arn = self._get_param("ResourceName") template = self.response_template(LIST_TAGS_FOR_RESOURCE_TEMPLATE) @@ -426,6 +452,36 @@ class RDS2Response(BaseResponse): template = self.response_template(DELETE_DB_PARAMETER_GROUP_TEMPLATE) return template.render(db_parameter_group=db_parameter_group) + def create_db_cluster(self): + kwargs = self._get_db_cluster_kwargs() + cluster = self.backend.create_db_cluster(kwargs) + template = self.response_template(CREATE_DB_CLUSTER_TEMPLATE) + return template.render(cluster=cluster) + + def describe_db_clusters(self): + _id = self._get_param("DBClusterIdentifier") + clusters = self.backend.describe_db_clusters(cluster_identifier=_id) + template = self.response_template(DESCRIBE_CLUSTERS_TEMPLATE) + return template.render(clusters=clusters) + + def delete_db_cluster(self): + _id = self._get_param("DBClusterIdentifier") + cluster = self.backend.delete_db_cluster(cluster_identifier=_id) + template = self.response_template(DELETE_CLUSTER_TEMPLATE) + return template.render(cluster=cluster) + + def start_db_cluster(self): + _id = self._get_param("DBClusterIdentifier") + cluster = self.backend.start_db_cluster(cluster_identifier=_id) + template = self.response_template(START_CLUSTER_TEMPLATE) + return template.render(cluster=cluster) + + def stop_db_cluster(self): + _id = self._get_param("DBClusterIdentifier") + cluster = self.backend.stop_db_cluster(cluster_identifier=_id) + template = self.response_template(STOP_CLUSTER_TEMPLATE) + return template.render(cluster=cluster) + CREATE_DATABASE_TEMPLATE = """ @@ -506,6 +562,24 @@ DELETE_DATABASE_TEMPLATE = """ + + {{ cluster.to_xml() }} + + + 7369556f-b70d-11c3-faca-6ba18376ea1b + +""" + +RESTORE_INSTANCE_FROM_SNAPSHOT_TEMPLATE = """ + + {{ database.to_xml() }} + + + 523e3218-afc7-11c3-90f5-f90431260ab4 + +""" + CREATE_SNAPSHOT_TEMPLATE = """ {{ snapshot.to_xml() }} @@ -748,3 +822,47 @@ REMOVE_TAGS_FROM_RESOURCE_TEMPLATE = """ + + {{ cluster.to_xml() }} + + + 523e3218-afc7-11c3-90f5-f90431260ab4 + +""" + +DESCRIBE_CLUSTERS_TEMPLATE = """ + + + {%- for cluster in clusters -%} + {{ cluster.to_xml() }} + {%- endfor -%} + + {% if marker %} + {{ marker }} + {% endif %} + + + 523e3218-afc7-11c3-90f5-f90431260ab4 + +""" + +START_CLUSTER_TEMPLATE = """ + + {{ cluster.to_xml() }} + + + 523e3218-afc7-11c3-90f5-f90431260ab9 + +""" + +STOP_CLUSTER_TEMPLATE = """ + + {{ cluster.to_xml() }} + + + 523e3218-afc7-11c3-90f5-f90431260ab8 + +""" diff --git a/tests/test_rds2/test_rds2.py b/tests/test_rds2/test_rds2.py index 93fe11380..435e8437d 100644 --- a/tests/test_rds2/test_rds2.py +++ b/tests/test_rds2/test_rds2.py @@ -603,6 +603,88 @@ def test_delete_db_snapshot(): ).should.throw(ClientError) +@mock_rds2 +def test_restore_db_instance_from_db_snapshot(): + conn = boto3.client("rds", region_name="us-west-2") + conn.create_db_instance( + DBInstanceIdentifier="db-primary-1", + AllocatedStorage=10, + Engine="postgres", + DBName="staging-postgres", + DBInstanceClass="db.m1.small", + MasterUsername="root", + MasterUserPassword="hunter2", + DBSecurityGroups=["my_sg"], + ) + conn.describe_db_instances()["DBInstances"].should.have.length_of(1) + + conn.create_db_snapshot( + DBInstanceIdentifier="db-primary-1", DBSnapshotIdentifier="snapshot-1" + ) + + # restore + new_instance = conn.restore_db_instance_from_db_snapshot( + DBInstanceIdentifier="db-restore-1", DBSnapshotIdentifier="snapshot-1" + )["DBInstance"] + new_instance["DBInstanceIdentifier"].should.equal("db-restore-1") + new_instance["DBInstanceClass"].should.equal("db.m1.small") + new_instance["StorageType"].should.equal("gp2") + new_instance["Engine"].should.equal("postgres") + new_instance["DBName"].should.equal("staging-postgres") + new_instance["DBParameterGroups"][0]["DBParameterGroupName"].should.equal( + "default.postgres9.3" + ) + new_instance["DBSecurityGroups"].should.equal( + [{"DBSecurityGroupName": "my_sg", "Status": "active"}] + ) + new_instance["Endpoint"]["Port"].should.equal(5432) + + # Verify it exists + conn.describe_db_instances()["DBInstances"].should.have.length_of(2) + conn.describe_db_instances(DBInstanceIdentifier="db-restore-1")[ + "DBInstances" + ].should.have.length_of(1) + + +@mock_rds2 +def test_restore_db_instance_from_db_snapshot_and_override_params(): + conn = boto3.client("rds", region_name="us-west-2") + conn.create_db_instance( + DBInstanceIdentifier="db-primary-1", + AllocatedStorage=10, + Engine="postgres", + DBName="staging-postgres", + DBInstanceClass="db.m1.small", + MasterUsername="root", + MasterUserPassword="hunter2", + Port=1234, + DBSecurityGroups=["my_sg"], + ) + conn.describe_db_instances()["DBInstances"].should.have.length_of(1) + conn.create_db_snapshot( + DBInstanceIdentifier="db-primary-1", DBSnapshotIdentifier="snapshot-1" + ) + + # restore with some updated attributes + new_instance = conn.restore_db_instance_from_db_snapshot( + DBInstanceIdentifier="db-restore-1", + DBSnapshotIdentifier="snapshot-1", + Port=10000, + VpcSecurityGroupIds=["new_vpc"], + )["DBInstance"] + new_instance["DBInstanceIdentifier"].should.equal("db-restore-1") + new_instance["DBParameterGroups"][0]["DBParameterGroupName"].should.equal( + "default.postgres9.3" + ) + new_instance["DBSecurityGroups"].should.equal( + [{"DBSecurityGroupName": "my_sg", "Status": "active"}] + ) + new_instance["VpcSecurityGroups"].should.equal( + [{"VpcSecurityGroupId": "new_vpc", "Status": "active"}] + ) + new_instance["Endpoint"]["Port"].should.equal(10000) + + @mock_rds2 def test_create_option_group(): conn = boto3.client("rds", region_name="us-west-2") diff --git a/tests/test_rds2/test_rds2_clusters.py b/tests/test_rds2/test_rds2_clusters.py new file mode 100644 index 000000000..29fbf9486 --- /dev/null +++ b/tests/test_rds2/test_rds2_clusters.py @@ -0,0 +1,305 @@ +import boto3 +import pytest +import sure # noqa + +from botocore.exceptions import ClientError +from moto import mock_rds2 +from moto.core import ACCOUNT_ID + + +@mock_rds2 +def test_describe_db_cluster_initial(): + client = boto3.client("rds", region_name="eu-north-1") + + resp = client.describe_db_clusters() + resp.should.have.key("DBClusters").should.have.length_of(0) + + +@mock_rds2 +def test_create_db_cluster_needs_master_username(): + client = boto3.client("rds", region_name="eu-north-1") + + with pytest.raises(ClientError) as ex: + client.create_db_cluster(DBClusterIdentifier="cluster-id", Engine="aurora") + err = ex.value.response["Error"] + err["Code"].should.equal("InvalidParameterValue") + err["Message"].should.equal( + "The parameter MasterUsername must be provided and must not be blank." + ) + + +@mock_rds2 +def test_create_db_cluster_needs_master_user_password(): + client = boto3.client("rds", region_name="eu-north-1") + + with pytest.raises(ClientError) as ex: + client.create_db_cluster( + DBClusterIdentifier="cluster-id", Engine="aurora", MasterUsername="root" + ) + err = ex.value.response["Error"] + err["Code"].should.equal("InvalidParameterValue") + err["Message"].should.equal( + "The parameter MasterUserPassword must be provided and must not be blank." + ) + + +@mock_rds2 +def test_create_db_cluster_needs_long_master_user_password(): + client = boto3.client("rds", region_name="eu-north-1") + + with pytest.raises(ClientError) as ex: + client.create_db_cluster( + DBClusterIdentifier="cluster-id", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2", + ) + err = ex.value.response["Error"] + err["Code"].should.equal("InvalidParameterValue") + err["Message"].should.equal( + "The parameter MasterUserPassword is not a valid password because it is shorter than 8 characters." + ) + + +@mock_rds2 +def test_create_db_cluster__verify_default_properties(): + client = boto3.client("rds", region_name="eu-north-1") + + resp = client.create_db_cluster( + DBClusterIdentifier="cluster-id", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2_", + ) + resp.should.have.key("DBCluster") + + cluster = resp["DBCluster"] + + cluster.shouldnt.have.key( + "DatabaseName" + ) # This was not supplied, so should not be returned + + cluster.should.have.key("AllocatedStorage").equal(1) + cluster.should.have.key("AvailabilityZones") + set(cluster["AvailabilityZones"]).should.equal( + {"eu-north-1a", "eu-north-1b", "eu-north-1c"} + ) + cluster.should.have.key("BackupRetentionPeriod").equal(1) + cluster.should.have.key("DBClusterIdentifier").equal("cluster-id") + cluster.should.have.key("DBClusterParameterGroup").equal("default.aurora5.6") + cluster.should.have.key("DBSubnetGroup").equal("default") + cluster.should.have.key("Status").equal("creating") + cluster.should.have.key("Endpoint").match( + "cluster-id.cluster-[a-z0-9]{12}.eu-north-1.rds.amazonaws.com" + ) + endpoint = cluster["Endpoint"] + expected_readonly = endpoint.replace( + "cluster-id.cluster-", "cluster-id.cluster-ro-" + ) + cluster.should.have.key("ReaderEndpoint").equal(expected_readonly) + cluster.should.have.key("MultiAZ").equal(False) + cluster.should.have.key("Engine").equal("aurora") + cluster.should.have.key("EngineVersion").equal("5.6.mysql_aurora.1.22.5") + cluster.should.have.key("Port").equal(3306) + cluster.should.have.key("MasterUsername").equal("root") + cluster.should.have.key("PreferredBackupWindow").equal("01:37-02:07") + cluster.should.have.key("PreferredMaintenanceWindow").equal("wed:02:40-wed:03:10") + cluster.should.have.key("ReadReplicaIdentifiers").equal([]) + cluster.should.have.key("DBClusterMembers").equal([]) + cluster.should.have.key("VpcSecurityGroups") + cluster.should.have.key("HostedZoneId") + cluster.should.have.key("StorageEncrypted").equal(False) + cluster.should.have.key("DbClusterResourceId").match(r"cluster-[A-Z0-9]{26}") + cluster.should.have.key("DBClusterArn").equal( + f"arn:aws:rds:eu-north-1:{ACCOUNT_ID}:cluster:cluster-id" + ) + cluster.should.have.key("AssociatedRoles").equal([]) + cluster.should.have.key("IAMDatabaseAuthenticationEnabled").equal(False) + cluster.should.have.key("EngineMode").equal("provisioned") + cluster.should.have.key("DeletionProtection").equal(False) + cluster.should.have.key("HttpEndpointEnabled").equal(False) + cluster.should.have.key("CopyTagsToSnapshot").equal(False) + cluster.should.have.key("CrossAccountClone").equal(False) + cluster.should.have.key("DomainMemberships").equal([]) + cluster.should.have.key("TagList").equal([]) + cluster.should.have.key("ClusterCreateTime") + + +@mock_rds2 +def test_create_db_cluster_with_database_name(): + client = boto3.client("rds", region_name="eu-north-1") + + resp = client.create_db_cluster( + DBClusterIdentifier="cluster-id", + DatabaseName="users", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2_", + ) + cluster = resp["DBCluster"] + cluster.should.have.key("DatabaseName").equal("users") + cluster.should.have.key("DBClusterIdentifier").equal("cluster-id") + cluster.should.have.key("DBClusterParameterGroup").equal("default.aurora5.6") + + +@mock_rds2 +def test_create_db_cluster_additional_parameters(): + client = boto3.client("rds", region_name="eu-north-1") + + resp = client.create_db_cluster( + AvailabilityZones=["eu-north-1b"], + DBClusterIdentifier="cluster-id", + Engine="aurora", + EngineVersion="5.6.mysql_aurora.1.19.2", + EngineMode="serverless", + MasterUsername="root", + MasterUserPassword="hunter2_", + Port=1234, + ) + + cluster = resp["DBCluster"] + + cluster.should.have.key("AvailabilityZones").equal(["eu-north-1b"]) + cluster.should.have.key("Engine").equal("aurora") + cluster.should.have.key("EngineVersion").equal("5.6.mysql_aurora.1.19.2") + cluster.should.have.key("EngineMode").equal("serverless") + cluster.should.have.key("Port").equal(1234) + + +@mock_rds2 +def test_describe_db_cluster_after_creation(): + client = boto3.client("rds", region_name="eu-north-1") + + client.create_db_cluster( + DBClusterIdentifier="cluster-id1", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2_", + ) + + client.create_db_cluster( + DBClusterIdentifier="cluster-id2", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2_", + ) + + client.describe_db_clusters()["DBClusters"].should.have.length_of(2) + + client.describe_db_clusters(DBClusterIdentifier="cluster-id2")[ + "DBClusters" + ].should.have.length_of(1) + + +@mock_rds2 +def test_delete_db_cluster(): + client = boto3.client("rds", region_name="eu-north-1") + + client.create_db_cluster( + DBClusterIdentifier="cluster-id", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2_", + ) + + client.delete_db_cluster(DBClusterIdentifier="cluster-id") + + client.describe_db_clusters()["DBClusters"].should.have.length_of(0) + + +@mock_rds2 +def test_start_db_cluster_unknown_cluster(): + client = boto3.client("rds", region_name="eu-north-1") + + with pytest.raises(ClientError) as ex: + client.start_db_cluster(DBClusterIdentifier="cluster-unknown") + err = ex.value.response["Error"] + err["Code"].should.equal("DBClusterNotFoundFault") + err["Message"].should.equal("DBCluster cluster-unknown not found.") + + +@mock_rds2 +def test_start_db_cluster_after_stopping(): + client = boto3.client("rds", region_name="eu-north-1") + + client.create_db_cluster( + DBClusterIdentifier="cluster-id", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2_", + ) + client.stop_db_cluster(DBClusterIdentifier="cluster-id") + + client.start_db_cluster(DBClusterIdentifier="cluster-id") + cluster = client.describe_db_clusters()["DBClusters"][0] + cluster["Status"].should.equal("available") + + +@mock_rds2 +def test_start_db_cluster_without_stopping(): + client = boto3.client("rds", region_name="eu-north-1") + + client.create_db_cluster( + DBClusterIdentifier="cluster-id", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2_", + ) + + with pytest.raises(ClientError) as ex: + client.start_db_cluster(DBClusterIdentifier="cluster-id") + err = ex.value.response["Error"] + err["Code"].should.equal("InvalidDBClusterStateFault") + err["Message"].should.equal("DbCluster cluster-id is not in stopped state.") + + +@mock_rds2 +def test_stop_db_cluster(): + client = boto3.client("rds", region_name="eu-north-1") + + client.create_db_cluster( + DBClusterIdentifier="cluster-id", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2_", + ) + + resp = client.stop_db_cluster(DBClusterIdentifier="cluster-id") + # Quirk of the AWS implementation - the immediate response show it's still available + cluster = resp["DBCluster"] + cluster["Status"].should.equal("available") + # For some time the status will be 'stopping' + # And finally it will be 'stopped' + cluster = client.describe_db_clusters()["DBClusters"][0] + cluster["Status"].should.equal("stopped") + + +@mock_rds2 +def test_stop_db_cluster_already_stopped(): + client = boto3.client("rds", region_name="eu-north-1") + + client.create_db_cluster( + DBClusterIdentifier="cluster-id", + Engine="aurora", + MasterUsername="root", + MasterUserPassword="hunter2_", + ) + client.stop_db_cluster(DBClusterIdentifier="cluster-id") + + # can't call stop on a stopped cluster + with pytest.raises(ClientError) as ex: + client.stop_db_cluster(DBClusterIdentifier="cluster-id") + err = ex.value.response["Error"] + err["Code"].should.equal("InvalidDBClusterStateFault") + err["Message"].should.equal("DbCluster cluster-id is not in available state.") + + +@mock_rds2 +def test_stop_db_cluster_unknown_cluster(): + client = boto3.client("rds", region_name="eu-north-1") + + with pytest.raises(ClientError) as ex: + client.stop_db_cluster(DBClusterIdentifier="cluster-unknown") + err = ex.value.response["Error"] + err["Code"].should.equal("DBClusterNotFoundFault") + err["Message"].should.equal("DBCluster cluster-unknown not found.")