Feature: Elasticache (#4668)

This commit is contained in:
Bert Blommers 2021-12-07 11:09:13 -01:00 committed by GitHub
parent 17c0cedbb2
commit 0c5a3cc8ca
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
13 changed files with 723 additions and 12 deletions

View File

@ -1843,6 +1843,77 @@
- [ ] update_nodegroup_version
</details>
## elasticache
<details>
<summary>4% implemented</summary>
- [ ] 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
</details>
## elasticbeanstalk
<details>
<summary>12% implemented</summary>
@ -4860,7 +4931,6 @@
- ebs
- ecr-public
- elastic-inference
- elasticache
- es
- finspace
- finspace-data

View File

@ -0,0 +1,100 @@
.. _implementedservice_elasticache:
.. |start-h3| raw:: html
<h3>
.. |end-h3| raw:: html
</h3>
===========
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

View File

@ -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):

View File

@ -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(

View File

@ -0,0 +1,4 @@
from .models import elasticache_backends
from ..core.models import base_decorator
mock_elasticache = base_decorator(elasticache_backends)

View File

@ -0,0 +1,65 @@
from moto.core.exceptions import RESTError
EXCEPTION_RESPONSE = """<?xml version="1.0"?>
<ErrorResponse xmlns="http://elasticache.amazonaws.com/doc/2015-02-02/">
<Error>
<Type>Sender</Type>
<Code>{{ error_type }}</Code>
<Message>{{ message }}</Message>
</Error>
<{{ request_id_tag }}>30c0dedb-92b1-4e2b-9be4-1188e3ed86ab</{{ request_id_tag }}>
</ErrorResponse>"""
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,
)

106
moto/elasticache/models.py Normal file
View File

@ -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)

View File

@ -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 = """<UserId>{{ user.id }}</UserId>
<UserName>{{ user.name }}</UserName>
<Status>{{ user.status }}</Status>
<Engine>{{ user.engine }}</Engine>
<MinimumEngineVersion>{{ user.minimum_engine_version }}</MinimumEngineVersion>
<AccessString>{{ user.access_string }}</AccessString>
<UserGroupIds>
{% for usergroupid in user.usergroupids %}
<member>{{ usergroupid }}</member>
{% endfor %}
</UserGroupIds>
<Authentication>
{% if user.no_password_required %}
<Type>no-password</Type>
{% else %}
<Type>password</Type>
<PasswordCount>{{ user.passwords|length }}</PasswordCount>
{% endif %}
</Authentication>
<ARN>{{ user.arn }}</ARN>"""
CREATE_USER_TEMPLATE = (
"""<CreateUserResponse xmlns="http://elasticache.amazonaws.com/doc/2015-02-02/">
<ResponseMetadata>
<RequestId>1549581b-12b7-11e3-895e-1334aEXAMPLE</RequestId>
</ResponseMetadata>
<CreateUserResult>
"""
+ USER_TEMPLATE
+ """
</CreateUserResult>
</CreateUserResponse>"""
)
DELETE_USER_TEMPLATE = (
"""<DeleteUserResponse xmlns="http://elasticache.amazonaws.com/doc/2015-02-02/">
<ResponseMetadata>
<RequestId>1549581b-12b7-11e3-895e-1334aEXAMPLE</RequestId>
</ResponseMetadata>
<DeleteUserResult>
"""
+ USER_TEMPLATE
+ """
</DeleteUserResult>
</DeleteUserResponse>"""
)
DESCRIBE_USERS_TEMPLATE = (
"""<DescribeUsersResponse xmlns="http://elasticache.amazonaws.com/doc/2015-02-02/">
<ResponseMetadata>
<RequestId>1549581b-12b7-11e3-895e-1334aEXAMPLE</RequestId>
</ResponseMetadata>
<DescribeUsersResult>
<Users>
{% for user in users %}
<member>
"""
+ USER_TEMPLATE
+ """
</member>
{% endfor %}
</Users>
<Marker></Marker>
</DescribeUsersResult>
</DescribeUsersResponse>"""
)

11
moto/elasticache/urls.py Normal file
View File

@ -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,
}

View File

@ -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

View File

View File

@ -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.")

View File

@ -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("<UserId>default</UserId>")