diff --git a/IMPLEMENTATION_COVERAGE.md b/IMPLEMENTATION_COVERAGE.md index dfe047a81..0f2e7c4ff 100644 --- a/IMPLEMENTATION_COVERAGE.md +++ b/IMPLEMENTATION_COVERAGE.md @@ -1843,6 +1843,77 @@ - [ ] update_nodegroup_version +## elasticache +
+4% implemented + +- [ ] add_tags_to_resource +- [ ] authorize_cache_security_group_ingress +- [ ] batch_apply_update_action +- [ ] batch_stop_update_action +- [ ] complete_migration +- [ ] copy_snapshot +- [ ] create_cache_cluster +- [ ] create_cache_parameter_group +- [ ] create_cache_security_group +- [ ] create_cache_subnet_group +- [ ] create_global_replication_group +- [ ] create_replication_group +- [ ] create_snapshot +- [X] create_user +- [ ] create_user_group +- [ ] decrease_node_groups_in_global_replication_group +- [ ] decrease_replica_count +- [ ] delete_cache_cluster +- [ ] delete_cache_parameter_group +- [ ] delete_cache_security_group +- [ ] delete_cache_subnet_group +- [ ] delete_global_replication_group +- [ ] delete_replication_group +- [ ] delete_snapshot +- [X] delete_user +- [ ] delete_user_group +- [ ] describe_cache_clusters +- [ ] describe_cache_engine_versions +- [ ] describe_cache_parameter_groups +- [ ] describe_cache_parameters +- [ ] describe_cache_security_groups +- [ ] describe_cache_subnet_groups +- [ ] describe_engine_default_parameters +- [ ] describe_events +- [ ] describe_global_replication_groups +- [ ] describe_replication_groups +- [ ] describe_reserved_cache_nodes +- [ ] describe_reserved_cache_nodes_offerings +- [ ] describe_service_updates +- [ ] describe_snapshots +- [ ] describe_update_actions +- [ ] describe_user_groups +- [X] describe_users +- [ ] disassociate_global_replication_group +- [ ] failover_global_replication_group +- [ ] increase_node_groups_in_global_replication_group +- [ ] increase_replica_count +- [ ] list_allowed_node_type_modifications +- [ ] list_tags_for_resource +- [ ] modify_cache_cluster +- [ ] modify_cache_parameter_group +- [ ] modify_cache_subnet_group +- [ ] modify_global_replication_group +- [ ] modify_replication_group +- [ ] modify_replication_group_shard_configuration +- [ ] modify_user +- [ ] modify_user_group +- [ ] purchase_reserved_cache_nodes_offering +- [ ] rebalance_slots_in_global_replication_group +- [ ] reboot_cache_cluster +- [ ] remove_tags_from_resource +- [ ] reset_cache_parameter_group +- [ ] revoke_cache_security_group_ingress +- [ ] start_migration +- [ ] test_failover +
+ ## elasticbeanstalk
12% implemented @@ -4860,7 +4931,6 @@ - ebs - ecr-public - elastic-inference -- elasticache - es - finspace - finspace-data diff --git a/docs/docs/services/elasticache.rst b/docs/docs/services/elasticache.rst new file mode 100644 index 000000000..1d72a928b --- /dev/null +++ b/docs/docs/services/elasticache.rst @@ -0,0 +1,100 @@ +.. _implementedservice_elasticache: + +.. |start-h3| raw:: html + +

+ +.. |end-h3| raw:: html + +

+ +=========== +elasticache +=========== + +.. autoclass:: moto.elasticache.models.ElastiCacheBackend + +|start-h3| Example usage |end-h3| + +.. sourcecode:: python + + @mock_elasticache + def test_elasticache_behaviour: + boto3.client("elasticache") + ... + + + +|start-h3| Implemented features for this service |end-h3| + +- [ ] add_tags_to_resource +- [ ] authorize_cache_security_group_ingress +- [ ] batch_apply_update_action +- [ ] batch_stop_update_action +- [ ] complete_migration +- [ ] copy_snapshot +- [ ] create_cache_cluster +- [ ] create_cache_parameter_group +- [ ] create_cache_security_group +- [ ] create_cache_subnet_group +- [ ] create_global_replication_group +- [ ] create_replication_group +- [ ] create_snapshot +- [X] create_user +- [ ] create_user_group +- [ ] decrease_node_groups_in_global_replication_group +- [ ] decrease_replica_count +- [ ] delete_cache_cluster +- [ ] delete_cache_parameter_group +- [ ] delete_cache_security_group +- [ ] delete_cache_subnet_group +- [ ] delete_global_replication_group +- [ ] delete_replication_group +- [ ] delete_snapshot +- [X] delete_user +- [ ] delete_user_group +- [ ] describe_cache_clusters +- [ ] describe_cache_engine_versions +- [ ] describe_cache_parameter_groups +- [ ] describe_cache_parameters +- [ ] describe_cache_security_groups +- [ ] describe_cache_subnet_groups +- [ ] describe_engine_default_parameters +- [ ] describe_events +- [ ] describe_global_replication_groups +- [ ] describe_replication_groups +- [ ] describe_reserved_cache_nodes +- [ ] describe_reserved_cache_nodes_offerings +- [ ] describe_service_updates +- [ ] describe_snapshots +- [ ] describe_update_actions +- [ ] describe_user_groups +- [X] describe_users + + Only the `user_id` parameter is currently supported. + Pagination is not yet implemented. + + +- [ ] disassociate_global_replication_group +- [ ] failover_global_replication_group +- [ ] increase_node_groups_in_global_replication_group +- [ ] increase_replica_count +- [ ] list_allowed_node_type_modifications +- [ ] list_tags_for_resource +- [ ] modify_cache_cluster +- [ ] modify_cache_parameter_group +- [ ] modify_cache_subnet_group +- [ ] modify_global_replication_group +- [ ] modify_replication_group +- [ ] modify_replication_group_shard_configuration +- [ ] modify_user +- [ ] modify_user_group +- [ ] purchase_reserved_cache_nodes_offering +- [ ] rebalance_slots_in_global_replication_group +- [ ] reboot_cache_cluster +- [ ] remove_tags_from_resource +- [ ] reset_cache_parameter_group +- [ ] revoke_cache_security_group_ingress +- [ ] start_migration +- [ ] test_failover + diff --git a/moto/__init__.py b/moto/__init__.py index 75d7bba20..b5879e4c0 100644 --- a/moto/__init__.py +++ b/moto/__init__.py @@ -171,6 +171,9 @@ mock_mediastoredata = lazy_load( mock_efs = lazy_load(".efs", "mock_efs") mock_wafv2 = lazy_load(".wafv2", "mock_wafv2") mock_sdb = lazy_load(".sdb", "mock_sdb") +mock_elasticache = lazy_load( + ".elasticache", "mock_elasticache", boto3_name="elasticache" +) class MockAll(ContextDecorator): diff --git a/moto/backend_index.py b/moto/backend_index.py index 11702c00a..b85b7e3a2 100644 --- a/moto/backend_index.py +++ b/moto/backend_index.py @@ -45,6 +45,7 @@ backend_url_patterns = [ ("efs", re.compile("https?://elasticfilesystem\\.(.+)\\.amazonaws.com")), ("efs", re.compile("https?://elasticfilesystem\\.amazonaws.com")), ("eks", re.compile("https?://eks\\.(.+)\\.amazonaws.com")), + ("elasticache", re.compile("https?://elasticache\\.(.+)\\.amazonaws\\.com")), ( "elasticbeanstalk", re.compile( diff --git a/moto/elasticache/__init__.py b/moto/elasticache/__init__.py new file mode 100644 index 000000000..44da97871 --- /dev/null +++ b/moto/elasticache/__init__.py @@ -0,0 +1,4 @@ +from .models import elasticache_backends +from ..core.models import base_decorator + +mock_elasticache = base_decorator(elasticache_backends) diff --git a/moto/elasticache/exceptions.py b/moto/elasticache/exceptions.py new file mode 100644 index 000000000..e30a2c5d9 --- /dev/null +++ b/moto/elasticache/exceptions.py @@ -0,0 +1,65 @@ +from moto.core.exceptions import RESTError + +EXCEPTION_RESPONSE = """ + + + Sender + {{ error_type }} + {{ message }} + + <{{ request_id_tag }}>30c0dedb-92b1-4e2b-9be4-1188e3ed86ab +""" + + +class ElastiCacheException(RESTError): + + code = 400 + + def __init__(self, *args, **kwargs): + kwargs.setdefault("template", "ecerror") + self.templates["ecerror"] = EXCEPTION_RESPONSE + super().__init__(*args, **kwargs) + + +class PasswordTooShort(ElastiCacheException): + + code = 404 + + def __init__(self, **kwargs): + super().__init__( + "InvalidParameterValue", + message="Passwords length must be between 16-128 characters.", + **kwargs, + ) + + +class PasswordRequired(ElastiCacheException): + + code = 404 + + def __init__(self, **kwargs): + super().__init__( + "InvalidParameterValue", + message="No password was provided. If you want to create/update the user without password, please use the NoPasswordRequired flag.", + **kwargs, + ) + + +class UserAlreadyExists(ElastiCacheException): + + code = 404 + + def __init__(self, **kwargs): + super().__init__( + "UserAlreadyExists", message="User user1 already exists.", **kwargs, + ) + + +class UserNotFound(ElastiCacheException): + + code = 404 + + def __init__(self, user_id, **kwargs): + super().__init__( + "UserNotFound", message=f"User {user_id} not found.", **kwargs, + ) diff --git a/moto/elasticache/models.py b/moto/elasticache/models.py new file mode 100644 index 000000000..ba8dd52a8 --- /dev/null +++ b/moto/elasticache/models.py @@ -0,0 +1,106 @@ +from boto3 import Session + +from moto.core import ACCOUNT_ID, BaseBackend, BaseModel + +from .exceptions import UserAlreadyExists, UserNotFound + + +class User(BaseModel): + def __init__( + self, + region, + user_id, + user_name, + access_string, + engine, + no_password_required, + passwords=None, + ): + self.id = user_id + self.name = user_name + self.engine = engine + self.passwords = passwords or [] + self.access_string = access_string + self.no_password_required = no_password_required + self.status = "active" + self.minimum_engine_version = "6.0" + self.usergroupids = [] + self.region = region + + @property + def arn(self): + return f"arn:aws:elasticache:{self.region}:{ACCOUNT_ID}:user:{self.id}" + + +class ElastiCacheBackend(BaseBackend): + """Implementation of ElastiCache APIs.""" + + def __init__(self, region_name=None): + self.region_name = region_name + self.users = dict() + self.users["default"] = User( + region=self.region_name, + user_id="default", + user_name="default", + engine="redis", + access_string="on ~* +@all", + no_password_required=True, + ) + + def reset(self): + region_name = self.region_name + self.__dict__ = {} + self.__init__(region_name) + + def create_user( + self, user_id, user_name, engine, passwords, access_string, no_password_required + ): + if user_id in self.users: + raise UserAlreadyExists + user = User( + region=self.region_name, + user_id=user_id, + user_name=user_name, + engine=engine, + passwords=passwords, + access_string=access_string, + no_password_required=no_password_required, + ) + self.users[user_id] = user + return user + + def delete_user(self, user_id): + if user_id in self.users: + user = self.users[user_id] + if user.status == "active": + user.status = "deleting" + return user + raise UserNotFound(user_id) + + def describe_users(self, user_id): + """ + Only the `user_id` parameter is currently supported. + Pagination is not yet implemented. + """ + if user_id: + if user_id in self.users: + user = self.users[user_id] + if user.status == "deleting": + self.users.pop(user_id) + return [user] + else: + raise UserNotFound(user_id) + return self.users.values() + + +elasticache_backends = {} +for available_region in Session().get_available_regions("elasticache"): + elasticache_backends[available_region] = ElastiCacheBackend(available_region) +for available_region in Session().get_available_regions( + "elasticache", partition_name="aws-us-gov" +): + elasticache_backends[available_region] = ElastiCacheBackend(available_region) +for available_region in Session().get_available_regions( + "elasticache", partition_name="aws-cn" +): + elasticache_backends[available_region] = ElastiCacheBackend(available_region) diff --git a/moto/elasticache/responses.py b/moto/elasticache/responses.py new file mode 100644 index 000000000..b63e029e8 --- /dev/null +++ b/moto/elasticache/responses.py @@ -0,0 +1,119 @@ +from moto.core.responses import BaseResponse +from .exceptions import PasswordTooShort, PasswordRequired +from .models import elasticache_backends + + +class ElastiCacheResponse(BaseResponse): + """Handler for ElastiCache requests and responses.""" + + @property + def elasticache_backend(self): + """Return backend instance specific for this region.""" + return elasticache_backends[self.region] + + def create_user(self): + params = self._get_params() + user_id = params.get("UserId") + user_name = params.get("UserName") + engine = params.get("Engine") + passwords = params.get("Passwords", []) + no_password_required = self._get_bool_param("NoPasswordRequired", False) + password_required = not no_password_required + if password_required and not passwords: + raise PasswordRequired + if any([len(p) < 16 for p in passwords]): + raise PasswordTooShort + access_string = params.get("AccessString") + user = self.elasticache_backend.create_user( + user_id=user_id, + user_name=user_name, + engine=engine, + passwords=passwords, + access_string=access_string, + no_password_required=no_password_required, + ) + template = self.response_template(CREATE_USER_TEMPLATE) + return template.render(user=user) + + def delete_user(self): + params = self._get_params() + user_id = params.get("UserId") + user = self.elasticache_backend.delete_user(user_id=user_id) + template = self.response_template(DELETE_USER_TEMPLATE) + return template.render(user=user) + + def describe_users(self): + params = self._get_params() + user_id = params.get("UserId") + users = self.elasticache_backend.describe_users(user_id=user_id) + template = self.response_template(DESCRIBE_USERS_TEMPLATE) + return template.render(users=users) + + +USER_TEMPLATE = """{{ user.id }} + {{ user.name }} + {{ user.status }} + {{ user.engine }} + {{ user.minimum_engine_version }} + {{ user.access_string }} + +{% for usergroupid in user.usergroupids %} + {{ usergroupid }} +{% endfor %} + + + {% if user.no_password_required %} + no-password + {% else %} + password + {{ user.passwords|length }} + {% endif %} + + {{ user.arn }}""" + + +CREATE_USER_TEMPLATE = ( + """ + + 1549581b-12b7-11e3-895e-1334aEXAMPLE + + + """ + + USER_TEMPLATE + + """ + +""" +) + +DELETE_USER_TEMPLATE = ( + """ + + 1549581b-12b7-11e3-895e-1334aEXAMPLE + + + """ + + USER_TEMPLATE + + """ + +""" +) + +DESCRIBE_USERS_TEMPLATE = ( + """ + + 1549581b-12b7-11e3-895e-1334aEXAMPLE + + + +{% for user in users %} + + """ + + USER_TEMPLATE + + """ + +{% endfor %} + + + +""" +) diff --git a/moto/elasticache/urls.py b/moto/elasticache/urls.py new file mode 100644 index 000000000..755d63237 --- /dev/null +++ b/moto/elasticache/urls.py @@ -0,0 +1,11 @@ +"""elasticache base URL and path.""" +from .responses import ElastiCacheResponse + +url_bases = [ + r"https?://elasticache\.(.+)\.amazonaws\.com", +] + + +url_paths = { + "{0}/$": ElastiCacheResponse.dispatch, +} diff --git a/scripts/scaffold.py b/scripts/scaffold.py index d70f89add..4189485d2 100755 --- a/scripts/scaffold.py +++ b/scripts/scaffold.py @@ -355,7 +355,8 @@ def _get_subtree(name, shape, replace_list, name_prefix=None): name_prefix = [] class_name = shape.__class__.__name__ - if class_name in ("StringShape", "Shape"): + shape_type = shape.type_name + if class_name in ("StringShape", "Shape") or shape_type == "structure": tree = etree.Element(name) # pylint: disable=c-extension-no-member if name_prefix: tree.text = f"{{{{ {name_prefix[-1]}.{to_snake_case(name)} }}}}" @@ -363,23 +364,24 @@ def _get_subtree(name, shape, replace_list, name_prefix=None): tree.text = f"{{{{ {to_snake_case(name)} }}}}" return tree - if class_name in ("ListShape",): + if class_name in ("ListShape",) or shape_type == "list": # pylint: disable=c-extension-no-member replace_list.append((name, name_prefix)) tree = etree.Element(name) t_member = etree.Element("member") tree.append(t_member) - for nested_name, nested_shape in shape.member.members.items(): - t_member.append( - _get_subtree( - nested_name, - nested_shape, - replace_list, - name_prefix + [singularize(name.lower())], + if hasattr(shape.member, "members"): + for nested_name, nested_shape in shape.member.members.items(): + t_member.append( + _get_subtree( + nested_name, + nested_shape, + replace_list, + name_prefix + [singularize(name.lower())], + ) ) - ) return tree - raise ValueError("Not supported Shape") + raise ValueError(f"Not supported Shape: {shape}") def get_response_query_template(service, operation): # pylint: disable=too-many-locals diff --git a/tests/test_elasticache/__init__.py b/tests/test_elasticache/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/test_elasticache/test_elasticache.py b/tests/test_elasticache/test_elasticache.py new file mode 100644 index 000000000..adee2ff0a --- /dev/null +++ b/tests/test_elasticache/test_elasticache.py @@ -0,0 +1,216 @@ +import boto3 +import pytest +import sure # noqa # pylint: disable=unused-import + +from botocore.exceptions import ClientError +from moto import mock_elasticache +from moto.core import ACCOUNT_ID + +# See our Development Tips on writing tests for hints on how to write good tests: +# http://docs.getmoto.org/en/latest/docs/contributing/development_tips/tests.html + + +@mock_elasticache +def test_create_user_no_password_required(): + client = boto3.client("elasticache", region_name="ap-southeast-1") + user_id = "user1" + resp = client.create_user( + UserId=user_id, + UserName="User1", + Engine="Redis", + AccessString="on ~* +@all", + NoPasswordRequired=True, + ) + + resp.should.have.key("UserId").equals(user_id) + resp.should.have.key("UserName").equals("User1") + resp.should.have.key("Status").equals("active") + resp.should.have.key("Engine").equals("Redis") + resp.should.have.key("MinimumEngineVersion").equals("6.0") + resp.should.have.key("AccessString").equals("on ~* +@all") + resp.should.have.key("UserGroupIds").equals([]) + resp.should.have.key("Authentication") + resp["Authentication"].should.have.key("Type").equals("no-password") + resp["Authentication"].shouldnt.have.key("PasswordCount") + resp.should.have.key("ARN").equals( + f"arn:aws:elasticache:ap-southeast-1:{ACCOUNT_ID}:user:{user_id}" + ) + + +@mock_elasticache +def test_create_user_with_password_too_short(): + client = boto3.client("elasticache", region_name="ap-southeast-1") + user_id = "user1" + with pytest.raises(ClientError) as exc: + client.create_user( + UserId=user_id, + UserName="User1", + Engine="Redis", + AccessString="on ~* +@all", + Passwords=["mysecretpass"], + ) + err = exc.value.response["Error"] + err["Code"].should.equal("InvalidParameterValue") + err["Message"].should.equal("Passwords length must be between 16-128 characters.") + + +@mock_elasticache +def test_create_user_with_password(): + client = boto3.client("elasticache", region_name="ap-southeast-1") + user_id = "user1" + resp = client.create_user( + UserId=user_id, + UserName="User1", + Engine="Redis", + AccessString="on ~* +@all", + Passwords=["mysecretpassthatsverylong"], + ) + + resp.should.have.key("UserId").equals(user_id) + resp.should.have.key("UserName").equals("User1") + resp.should.have.key("Status").equals("active") + resp.should.have.key("Engine").equals("Redis") + resp.should.have.key("MinimumEngineVersion").equals("6.0") + resp.should.have.key("AccessString").equals("on ~* +@all") + resp.should.have.key("UserGroupIds").equals([]) + resp.should.have.key("Authentication") + resp["Authentication"].should.have.key("Type").equals("password") + resp["Authentication"].should.have.key("PasswordCount").equals(1) + resp.should.have.key("ARN").equals( + f"arn:aws:elasticache:ap-southeast-1:{ACCOUNT_ID}:user:{user_id}" + ) + + +@mock_elasticache +def test_create_user_without_password(): + client = boto3.client("elasticache", region_name="ap-southeast-1") + with pytest.raises(ClientError) as exc: + client.create_user( + UserId="user1", UserName="User1", Engine="Redis", AccessString="?" + ) + err = exc.value.response["Error"] + err["Code"].should.equal("InvalidParameterValue") + err["Message"].should.equal( + "No password was provided. If you want to create/update the user without password, please use the NoPasswordRequired flag." + ) + + +@mock_elasticache +def test_create_user_twice(): + client = boto3.client("elasticache", region_name="ap-southeast-1") + user_id = "user1" + client.create_user( + UserId=user_id, + UserName="User1", + Engine="Redis", + AccessString="on ~* +@all", + Passwords=["mysecretpassthatsverylong"], + ) + + with pytest.raises(ClientError) as exc: + client.create_user( + UserId=user_id, + UserName="User1", + Engine="Redis", + AccessString="on ~* +@all", + Passwords=["mysecretpassthatsverylong"], + ) + err = exc.value.response["Error"] + err["Code"].should.equal("UserAlreadyExists") + err["Message"].should.equal("User user1 already exists.") + + +@mock_elasticache +def test_delete_user_unknown(): + client = boto3.client("elasticache", region_name="ap-southeast-1") + with pytest.raises(ClientError) as exc: + client.delete_user(UserId="unknown") + err = exc.value.response["Error"] + err["Code"].should.equal("UserNotFound") + err["Message"].should.equal("User unknown not found.") + + +@mock_elasticache +def test_delete_user(): + client = boto3.client("elasticache", region_name="ap-southeast-1") + + client.create_user( + UserId="user1", + UserName="User1", + Engine="Redis", + AccessString="on ~* +@all", + Passwords=["mysecretpassthatsverylong"], + ) + + client.delete_user(UserId="user1") + + # Initial status is 'deleting' + resp = client.describe_users(UserId="user1") + resp["Users"][0]["Status"].should.equal("deleting") + + # User is only deleted after some time + with pytest.raises(ClientError) as exc: + client.describe_users(UserId="unknown") + exc.value.response["Error"]["Code"].should.equal("UserNotFound") + + +@mock_elasticache +def test_describe_users_initial(): + client = boto3.client("elasticache", region_name="us-east-2") + resp = client.describe_users() + + resp.should.have.key("Users").length_of(1) + resp["Users"][0].should.equal( + { + "UserId": "default", + "UserName": "default", + "Status": "active", + "Engine": "redis", + "MinimumEngineVersion": "6.0", + "AccessString": "on ~* +@all", + "UserGroupIds": [], + "Authentication": {"Type": "no-password"}, + "ARN": f"arn:aws:elasticache:us-east-2:{ACCOUNT_ID}:user:default", + } + ) + + +@mock_elasticache +def test_describe_users(): + client = boto3.client("elasticache", region_name="ap-southeast-1") + + client.create_user( + UserId="user1", + UserName="User1", + Engine="Redis", + AccessString="on ~* +@all", + Passwords=["mysecretpassthatsverylong"], + ) + + resp = client.describe_users() + + resp.should.have.key("Users").length_of(2) + resp["Users"].should.contain( + { + "UserId": "user1", + "UserName": "User1", + "Status": "active", + "Engine": "Redis", + "MinimumEngineVersion": "6.0", + "AccessString": "on ~* +@all", + "UserGroupIds": [], + "Authentication": {"Type": "password", "PasswordCount": 1}, + "ARN": f"arn:aws:elasticache:ap-southeast-1:{ACCOUNT_ID}:user:user1", + } + ) + + +@mock_elasticache +def test_describe_users_unknown_userid(): + client = boto3.client("elasticache", region_name="ap-southeast-1") + + with pytest.raises(ClientError) as exc: + client.describe_users(UserId="unknown") + err = exc.value.response["Error"] + err["Code"].should.equal("UserNotFound") + err["Message"].should.equal("User unknown not found.") diff --git a/tests/test_elasticache/test_server.py b/tests/test_elasticache/test_server.py new file mode 100644 index 000000000..1217d5d50 --- /dev/null +++ b/tests/test_elasticache/test_server.py @@ -0,0 +1,14 @@ +import sure # noqa # pylint: disable=unused-import + +import moto.server as server + + +def test_elasticache_describe_users(): + backend = server.create_backend_app("elasticache") + test_client = backend.test_client() + + data = "Action=DescribeUsers" + headers = {"Host": "elasticache.us-east-1.amazonaws.com"} + resp = test_client.post("/", data=data, headers=headers) + resp.status_code.should.equal(200) + str(resp.data).should.contain("default")