diff --git a/changelogs/fragments/20260122-elasticache-valkey.yaml b/changelogs/fragments/20260122-elasticache-valkey.yaml new file mode 100644 index 00000000000..9fb7c25df0e --- /dev/null +++ b/changelogs/fragments/20260122-elasticache-valkey.yaml @@ -0,0 +1,3 @@ +minor_changes: + - elasticache - add replication group support (including Valkey engines) for create/modify/delete workflows, with replication-group specific parameters grouped under ``replication_group_options`` (https://github.com/ansible-collections/community.aws/pull/2386). + - elasticache_parameter_group - add Valkey and newer cache parameter group families to the documented choices (https://github.com/ansible-collections/community.aws/pull/2386). diff --git a/plugins/modules/elasticache.py b/plugins/modules/elasticache.py index fd823fcdda7..dda2cdeac93 100644 --- a/plugins/modules/elasticache.py +++ b/plugins/modules/elasticache.py @@ -30,7 +30,8 @@ engine: description: - Name of the cache engine to be used. - - Supported values are C(redis) and C(memcached). + - Supported values are C(redis) and C(memcached) when managing cache clusters. + - Supported values are C(redis) and C(valkey) when I(replication_group=true). default: memcached type: str cache_engine_version: @@ -49,6 +50,72 @@ - Required when I(state=present). type: int default: 1 + replication_group: + description: + - Whether to manage a replication group instead of a cache cluster. + type: bool + default: false + version_added: 10.1.0 + replication_group_options: + description: + - Additional options used when I(replication_group=true). + type: dict + version_added: 10.1.0 + suboptions: + replication_group_description: + description: + - Description for the replication group. + - Required when I(replication_group=true) and I(state=present). + type: str + version_added: 10.1.0 + num_cache_clusters: + description: + - The number of cache clusters in the replication group. + - Only used when I(replication_group=true). + type: int + version_added: 10.1.0 + replicas_per_node_group: + description: + - The number of replica nodes in each node group (shard) for the replication group. + - Only used when I(replication_group=true). + type: int + version_added: 10.1.0 + num_node_groups: + description: + - The number of node groups (shards) for the replication group. + - Only used when I(replication_group=true). + type: int + version_added: 10.1.0 + automatic_failover: + description: + - Whether automatic failover is enabled for the replication group. + - Only used when I(replication_group=true). + type: bool + version_added: 10.1.0 + multi_az_enabled: + description: + - Whether Multi-AZ is enabled for the replication group. + - Only used when I(replication_group=true). + type: bool + version_added: 10.1.0 + transit_encryption_enabled: + description: + - Whether in-transit encryption is enabled for the replication group. + - Only used when I(replication_group=true). + type: bool + version_added: 10.1.0 + cluster_mode: + description: + - Cluster mode setting for the replication group (for example, C(enabled) or C(disabled)). + - Only used when I(replication_group=true). + type: str + version_added: 10.1.0 + retain_primary_cluster: + description: + - Whether to retain the primary cluster when deleting a replication group. + - Only used when I(replication_group=true). + type: bool + version_added: 10.1.0 cache_port: description: - The port number on which each of the cache nodes will accept @@ -128,6 +195,17 @@ community.aws.elasticache: name: "test-please-delete" state: rebooted + +- name: Create a Valkey replication group + community.aws.elasticache: + name: "test-valkey-rg" + state: present + replication_group: true + replication_group_options: + replication_group_description: "Valkey test replication group" + num_cache_clusters: 1 + engine: valkey + node_type: cache.t3.micro """ from time import sleep @@ -473,6 +551,304 @@ def _get_nodes_to_remove(self): return cache_node_ids[-num_nodes_to_remove:] +class ReplicationGroupManager: + """Handles elasticache replication group creation and destruction""" + + # These statuses mean the replication group exists (it may or may not be + # ready for modification). + EXIST_STATUSES = ["available", "creating", "modifying", "deleting", "snapshotting", "create-failed"] + IN_PROGRESS_STATUSES = ["creating", "modifying", "snapshotting"] + FAILED_STATUS = "create-failed" + + def __init__( + self, + module, + name, + engine, + cache_engine_version, + node_type, + num_cache_clusters, + cache_port, + cache_parameter_group, + cache_subnet_group, + cache_security_groups, + security_group_ids, + replication_group_description, + automatic_failover, + multi_az_enabled, + transit_encryption_enabled, + replicas_per_node_group, + num_node_groups, + cluster_mode, + retain_primary_cluster, + wait, + ): + self.module = module + self.name = name + self.engine = engine.lower() + self.cache_engine_version = cache_engine_version + self.node_type = node_type + self.num_cache_clusters = num_cache_clusters + self.cache_port = cache_port + self.cache_parameter_group = cache_parameter_group + self.cache_subnet_group = cache_subnet_group + self.cache_security_groups = cache_security_groups + self.security_group_ids = security_group_ids + self.replication_group_description = replication_group_description + self.automatic_failover = automatic_failover + self.multi_az_enabled = multi_az_enabled + self.transit_encryption_enabled = transit_encryption_enabled + self.replicas_per_node_group = replicas_per_node_group + self.num_node_groups = num_node_groups + self.cluster_mode = cluster_mode + self.retain_primary_cluster = retain_primary_cluster + self.wait = wait + + self.changed = False + self.data = None + self.status = "gone" + self.conn = self._get_elasticache_connection() + self._refresh_data() + + def ensure_present(self): + """Ensure replication group exists or create it if not""" + if self.exists(): + self.sync() + else: + self.create() + + def ensure_absent(self): + """Ensure replication group is gone or delete it if not""" + self.delete() + + def ensure_rebooted(self): + """Replication group reboot is not supported by this module""" + self.module.fail_json(msg="state 'rebooted' is not supported when replication_group is true") + + def exists(self): + """Check if replication group exists""" + return self.status in self.EXIST_STATUSES + + def create(self): + """Create an ElastiCache replication group""" + if self.status == "available": + return + if self.status in self.IN_PROGRESS_STATUSES: + if self.wait: + self._wait_for_status("available") + return + if self.status == self.FAILED_STATUS: + self.module.fail_json( + msg=f"'{self.name}' is in {self.status} status. Cannot create. Delete and recreate the replication group." + ) + if self.status == "deleting": + if self.wait: + self._wait_for_status("gone") + else: + self.module.fail_json(msg=f"'{self.name}' is currently deleting. Cannot create.") + + kwargs = dict( + ReplicationGroupId=self.name, + ReplicationGroupDescription=self.replication_group_description, + CacheNodeType=self.node_type, + Engine=self.engine, + ) + if self.cache_engine_version: + kwargs["EngineVersion"] = self.cache_engine_version + if self.num_cache_clusters is not None: + kwargs["NumCacheClusters"] = self.num_cache_clusters + if self.cache_port is not None: + kwargs["Port"] = self.cache_port + if self.cache_parameter_group: + kwargs["CacheParameterGroupName"] = self.cache_parameter_group + if self.cache_subnet_group: + kwargs["CacheSubnetGroupName"] = self.cache_subnet_group + if self.cache_security_groups: + kwargs["CacheSecurityGroupNames"] = self.cache_security_groups + if self.security_group_ids: + kwargs["SecurityGroupIds"] = self.security_group_ids + if self.automatic_failover is not None: + kwargs["AutomaticFailoverEnabled"] = self.automatic_failover + if self.multi_az_enabled is not None: + kwargs["MultiAZEnabled"] = self.multi_az_enabled + if self.transit_encryption_enabled is not None: + kwargs["TransitEncryptionEnabled"] = self.transit_encryption_enabled + if self.replicas_per_node_group is not None: + kwargs["ReplicasPerNodeGroup"] = self.replicas_per_node_group + if self.num_node_groups is not None: + kwargs["NumNodeGroups"] = self.num_node_groups + if self.cluster_mode is not None: + kwargs["ClusterMode"] = self.cluster_mode + + try: + self.conn.create_replication_group(**kwargs) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + self.module.fail_json_aws(e, msg="Failed to create replication group") + + self._refresh_data() + self.changed = True + if self.wait: + self._wait_for_status("available") + return True + + def delete(self): + """Destroy an ElastiCache replication group""" + if self.status == "gone": + return + if self.status == "deleting": + if self.wait: + self._wait_for_status("gone") + return + if self.status in self.IN_PROGRESS_STATUSES: + if self.wait: + self._wait_for_status("available") + else: + self.module.fail_json(msg=f"'{self.name}' is currently {self.status}. Cannot delete.") + + kwargs = dict(ReplicationGroupId=self.name) + if self.retain_primary_cluster is not None: + kwargs["RetainPrimaryCluster"] = self.retain_primary_cluster + + try: + response = self.conn.delete_replication_group(**kwargs) + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + self.module.fail_json_aws(e, msg="Failed to delete replication group") + + replication_group_data = response.get("ReplicationGroup", None) + self._refresh_data(replication_group_data) + + self.changed = True + if self.wait: + self._wait_for_status("gone") + + def sync(self): + """Sync settings to replication group if required""" + if not self.exists(): + self.module.fail_json(msg=f"'{self.name}' is {self.status}. Cannot sync.") + + if self.status == self.FAILED_STATUS: + self.module.fail_json( + msg=f"'{self.name}' is in {self.status} status. Cannot sync. Delete and recreate the replication group." + ) + + if self.status in self.IN_PROGRESS_STATUSES: + if self.wait: + self._wait_for_status("available") + else: + return + + if self._requires_modification(): + self.modify() + + def modify(self): + """Modify the replication group.""" + kwargs = dict(ReplicationGroupId=self.name, ApplyImmediately=True) + if self.cache_engine_version: + kwargs["EngineVersion"] = self.cache_engine_version + if self.cache_parameter_group: + kwargs["CacheParameterGroupName"] = self.cache_parameter_group + if self.cache_security_groups: + kwargs["CacheSecurityGroupNames"] = self.cache_security_groups + if self.security_group_ids: + kwargs["SecurityGroupIds"] = self.security_group_ids + if self.automatic_failover is not None: + kwargs["AutomaticFailoverEnabled"] = self.automatic_failover + if self.multi_az_enabled is not None: + kwargs["MultiAZEnabled"] = self.multi_az_enabled + if self.transit_encryption_enabled is not None: + kwargs["TransitEncryptionEnabled"] = self.transit_encryption_enabled + + try: + self.conn.modify_replication_group(**kwargs) + except botocore.exceptions.ClientError as e: + self.module.fail_json_aws(e, msg="Failed to modify replication group") + + self._refresh_data() + self.changed = True + if self.wait: + self._wait_for_status("available") + + def get_info(self): + """Return basic info about the replication group""" + info = {"name": self.name, "status": self.status} + if self.data: + info["data"] = self.data + return info + + def _wait_for_status(self, awaited_status): + """Wait for status to change from present status to awaited_status""" + status_map = {"creating": "available", "modifying": "available", "snapshotting": "available", "deleting": "gone"} + if self.status == awaited_status: + return + if status_map[self.status] != awaited_status: + self.module.fail_json( + msg=f"Invalid awaited status. '{self.status}' cannot transition to '{awaited_status}'" + ) + + if awaited_status not in set(status_map.values()): + self.module.fail_json(msg=f"'{awaited_status}' is not a valid awaited status.") + + while True: + sleep(1) + self._refresh_data() + if self.status == awaited_status: + break + + def _requires_modification(self): + """Check if replication group requires (nondestructive) modification""" + if self.cache_engine_version and self.data.get("EngineVersion") != self.cache_engine_version: + return True + + cache_security_groups = [sg["CacheSecurityGroupName"] for sg in self.data.get("CacheSecurityGroups", [])] + if self.cache_security_groups and set(cache_security_groups) != set(self.cache_security_groups): + return True + + if self.security_group_ids: + vpc_security_groups = [sg["SecurityGroupId"] for sg in self.data.get("SecurityGroups", [])] + if set(vpc_security_groups) != set(self.security_group_ids): + return True + + if self.automatic_failover is not None and self.data.get("AutomaticFailover") != self.automatic_failover: + return True + + if self.multi_az_enabled is not None and self.data.get("MultiAZ") != self.multi_az_enabled: + return True + + if ( + self.transit_encryption_enabled is not None + and self.data.get("TransitEncryptionEnabled") != self.transit_encryption_enabled + ): + return True + + if self.cache_parameter_group: + current_group = self.data.get("CacheParameterGroup", {}).get("CacheParameterGroupName") + if current_group and current_group != self.cache_parameter_group: + return True + + return False + + def _get_elasticache_connection(self): + """Get an elasticache connection""" + try: + return self.module.client("elasticache") + except (botocore.exceptions.ClientError, botocore.exceptions.BotoCoreError) as e: + self.module.fail_json_aws(e, msg="Failed to connect to AWS") + + def _refresh_data(self, replication_group_data=None): + """Refresh data about this replication group""" + if replication_group_data is None: + try: + response = self.conn.describe_replication_groups(ReplicationGroupId=self.name) + except is_boto3_error_code("ReplicationGroupNotFoundFault"): + self.data = None + self.status = "gone" + return + except botocore.exceptions.ClientError as e: # pylint: disable=duplicate-except + self.module.fail_json_aws(e, msg="Failed to describe replication groups") + replication_group_data = response["ReplicationGroups"][0] + self.data = replication_group_data + self.status = self.data["Status"] + def main(): """elasticache ansible module""" argument_spec = dict( @@ -482,6 +858,21 @@ def main(): cache_engine_version=dict(default=""), node_type=dict(default="cache.t2.small"), num_nodes=dict(default=1, type="int"), + replication_group=dict(default=False, type="bool"), + replication_group_options=dict( + type="dict", + options=dict( + replication_group_description=dict(type="str"), + num_cache_clusters=dict(type="int"), + replicas_per_node_group=dict(type="int"), + num_node_groups=dict(type="int"), + automatic_failover=dict(type="bool"), + multi_az_enabled=dict(type="bool"), + transit_encryption_enabled=dict(type="bool"), + cluster_mode=dict(type="str"), + retain_primary_cluster=dict(type="bool"), + ), + ), # alias for compat with the original PR 1950 cache_parameter_group=dict(default="", aliases=["parameter_group"]), cache_port=dict(type="int"), @@ -511,29 +902,87 @@ def main(): wait = module.params["wait"] hard_modify = module.params["hard_modify"] cache_parameter_group = module.params["cache_parameter_group"] + replication_group = module.params["replication_group"] + replication_group_options = module.params["replication_group_options"] or {} + replication_group_description = replication_group_options.get("replication_group_description") + num_cache_clusters = replication_group_options.get("num_cache_clusters") + replicas_per_node_group = replication_group_options.get("replicas_per_node_group") + num_node_groups = replication_group_options.get("num_node_groups") + automatic_failover = replication_group_options.get("automatic_failover") + multi_az_enabled = replication_group_options.get("multi_az_enabled") + transit_encryption_enabled = replication_group_options.get("transit_encryption_enabled") + cluster_mode = replication_group_options.get("cluster_mode") + retain_primary_cluster = replication_group_options.get("retain_primary_cluster") if cache_subnet_group and cache_security_groups: module.fail_json(msg="Can't specify both cache_subnet_group and cache_security_groups") - if state == "present" and not num_nodes: + if replication_group: + if engine not in ["redis", "valkey"]: + module.fail_json(msg="When replication_group is true, engine must be 'redis' or 'valkey'") + if len(name) > 40: + module.fail_json(msg="'name' must be 40 characters or fewer when replication_group is true") + if state == "present" and not replication_group_description: + module.fail_json( + msg="'replication_group_options.replication_group_description' is required when replication_group is true" + ) + if state == "rebooted": + module.fail_json(msg="state 'rebooted' is not supported when replication_group is true") + if state == "present" and not (num_cache_clusters or num_node_groups): + module.fail_json( + msg=( + "replication groups require " + "'replication_group_options.num_cache_clusters' or " + "'replication_group_options.num_node_groups'" + ) + ) + else: + if engine not in ["redis", "memcached"]: + module.fail_json(msg="When replication_group is false, engine must be 'redis' or 'memcached'") + + if not replication_group and state == "present" and not num_nodes: module.fail_json(msg="'num_nodes' is a required parameter. Please specify num_nodes > 0") - elasticache_manager = ElastiCacheManager( - module, - name, - engine, - cache_engine_version, - node_type, - num_nodes, - cache_port, - cache_parameter_group, - cache_subnet_group, - cache_security_groups, - security_group_ids, - zone, - wait, - hard_modify, - ) + if replication_group: + elasticache_manager = ReplicationGroupManager( + module, + name, + engine, + cache_engine_version, + node_type, + num_cache_clusters, + cache_port, + cache_parameter_group, + cache_subnet_group, + cache_security_groups, + security_group_ids, + replication_group_description, + automatic_failover, + multi_az_enabled, + transit_encryption_enabled, + replicas_per_node_group, + num_node_groups, + cluster_mode, + retain_primary_cluster, + wait, + ) + else: + elasticache_manager = ElastiCacheManager( + module, + name, + engine, + cache_engine_version, + node_type, + num_nodes, + cache_port, + cache_parameter_group, + cache_subnet_group, + cache_security_groups, + security_group_ids, + zone, + wait, + hard_modify, + ) if state == "present": elasticache_manager.ensure_present() diff --git a/plugins/modules/elasticache_parameter_group.py b/plugins/modules/elasticache_parameter_group.py index e740e0bb53a..505f249d0e4 100644 --- a/plugins/modules/elasticache_parameter_group.py +++ b/plugins/modules/elasticache_parameter_group.py @@ -20,7 +20,22 @@ description: - The name of the cache parameter group family that the cache parameter group can be used with. Required when creating a cache parameter group. - choices: ['memcached1.4', 'memcached1.5', 'redis2.6', 'redis2.8', 'redis3.2', 'redis4.0', 'redis5.0', 'redis6.x'] + - The values C(memcached1.6), C(valkey7), and C(valkey8) were added in release C(11.1.0). + choices: + [ + 'memcached1.4', + 'memcached1.5', + 'memcached1.6', + 'redis2.6', + 'redis2.8', + 'redis3.2', + 'redis4.0', + 'redis5.0', + 'redis6.x', + 'redis7', + 'valkey7', + 'valkey8', + ] type: str name: description: @@ -294,12 +309,16 @@ def main(): choices=[ "memcached1.4", "memcached1.5", + "memcached1.6", "redis2.6", "redis2.8", "redis3.2", "redis4.0", "redis5.0", "redis6.x", + "redis7", + "valkey7", + "valkey8", ], ), name=dict(required=True, type="str"), diff --git a/tests/integration/targets/elasticache/defaults/main.yml b/tests/integration/targets/elasticache/defaults/main.yml index 1e1f60c90bd..ad0b201dbe0 100644 --- a/tests/integration/targets/elasticache/defaults/main.yml +++ b/tests/integration/targets/elasticache/defaults/main.yml @@ -6,5 +6,7 @@ vpc_cidr_prefix: '10.{{ 256 | random(seed=vpc_seed) }}' elasticache_redis_sg_name: "{{ resource_prefix }}-elasticache-test-redis-sg" elasticache_redis_test_name: "{{ resource_prefix }}-redis-test" +elasticache_valkey_test_name: "{{ (resource_prefix ~ '-valkey')[:40] }}" +elasticache_valkey_sg_name: "{{ resource_prefix }}-elasticache-test-valkey-sg" elasticache_subnet_group_name: "{{ resource_prefix }}-elasticache-test-vpc-subnet-group" elasticache_redis_port: 6379 diff --git a/tests/integration/targets/elasticache/tasks/main.yml b/tests/integration/targets/elasticache/tasks/main.yml index 9664a70f14e..cfc04a6350c 100644 --- a/tests/integration/targets/elasticache/tasks/main.yml +++ b/tests/integration/targets/elasticache/tasks/main.yml @@ -18,6 +18,9 @@ cidr_block: "{{ vpc_cidr_prefix }}.0.0/16" state: present register: elasticache_vpc + tags: + - redis + - valkey - name: Create subnet 1 in this VPC to launch Elasticache instances into ec2_vpc_subnet: @@ -25,6 +28,9 @@ cidr: "{{ vpc_cidr_prefix }}.1.0/24" state: present register: elasticache_vpc_subnet_1 + tags: + - redis + - valkey - name: Create subnet 2 in this VPC to launch Elasticache instances into ec2_vpc_subnet: @@ -32,6 +38,9 @@ cidr: "{{ vpc_cidr_prefix }}.2.0/24" state: present register: elasticache_vpc_subnet_2 + tags: + - redis + - valkey - name: Create Elasticache Subnet Group (grouping two subnets together) elasticache_subnet_group: @@ -41,6 +50,9 @@ - "{{ elasticache_vpc_subnet_1.subnet.id }}" - "{{ elasticache_vpc_subnet_2.subnet.id }}" state: present + tags: + - redis + - valkey # == Actual testing of the elasticache module == @@ -54,6 +66,8 @@ num_nodes: 1 state: present register: elasticache_redis + tags: + - redis - name: Assert that task worked assert: @@ -62,6 +76,8 @@ - elasticache_redis.elasticache.data is defined - elasticache_redis.elasticache.name == elasticache_redis_test_name - elasticache_redis.elasticache.data.CacheSubnetGroupName == elasticache_subnet_group_name + tags: + - redis - name: Add security group for Redis access in Elasticache ec2_security_group: @@ -74,6 +90,8 @@ to_port: "{{ elasticache_redis_port }}" cidr_ip: 10.31.0.0/16 register: elasticache_redis_sg + tags: + - redis - name: Update Redis Elasticache config with security group (to if changes to existing setup work) elasticache: @@ -86,6 +104,8 @@ security_group_ids: "{{ elasticache_redis_sg.group_id }}" state: present register: elasticache_redis_new + tags: + - redis - name: Assert that task worked assert: @@ -94,20 +114,28 @@ - elasticache_redis_new.elasticache.data is defined - elasticache_redis_new.elasticache.data.Engine == "redis" - elasticache_redis_new.elasticache.data.SecurityGroups.0.SecurityGroupId == elasticache_redis_sg.group_id + tags: + - redis - name: Describe all Elasticache clusters elasticache_info: {} register: elasticache_info + tags: + - redis - assert: that: - '"elasticache_clusters" in elasticache_info' - elasticache_info.elasticache_clusters | length >= 1 + tags: + - redis - name: Describe Elasticache Redis cluster elasticache_info: name: "{{ elasticache_redis.elasticache.name }}" register: elasticache_info + tags: + - redis - assert: that: @@ -169,6 +197,53 @@ - '"status" in elasticache_info.elasticache_clusters[0].security_groups[0]' - elasticache_info.elasticache_clusters[0].security_groups[0].security_group_id == elasticache_redis_sg.group_id - elasticache_info.elasticache_clusters[0].security_groups[0].status == "active" + tags: + - redis + + - name: Create Valkey replication group on Elasticache in VPC subnets + ec2_security_group: + name: "{{ elasticache_valkey_sg_name }}" + description: Allow access to Elasticache Valkey for integration tests + vpc_id: "{{ elasticache_vpc.vpc.id }}" + rules: + - proto: tcp + from_port: "{{ elasticache_redis_port }}" + to_port: "{{ elasticache_redis_port }}" + cidr_ip: 10.31.0.0/16 + register: elasticache_valkey_sg + tags: + - valkey + + - name: Create Valkey replication group on Elasticache in VPC subnets + elasticache: + name: "{{ elasticache_valkey_test_name }}" + replication_group: true + replication_group_options: + replication_group_description: "Valkey replication group for integration tests" + num_cache_clusters: 1 + transit_encryption_enabled: false + engine: valkey + node_type: cache.t3.micro + cache_port: "{{ elasticache_redis_port }}" + cache_subnet_group: "{{ elasticache_subnet_group_name }}" + security_group_ids: + - "{{ elasticache_valkey_sg.group_id }}" + state: present + register: elasticache_valkey + tags: + - valkey + + - name: Assert that task worked + assert: + that: + - elasticache_valkey is changed + - elasticache_valkey.elasticache.data is defined + - elasticache_valkey.elasticache.name == elasticache_valkey_test_name + - elasticache_valkey.elasticache.data.ReplicationGroupId == elasticache_valkey_test_name + - elasticache_valkey.elasticache.data.Status == "available" + - elasticache_valkey.elasticache.data.TransitEncryptionEnabled == false + tags: + - valkey always: @@ -179,6 +254,17 @@ name: "{{ elasticache_redis_test_name }}" engine: redis state: absent + tags: + - redis + + - name: Make sure test Valkey replication group is deleted again from Elasticache + elasticache: + name: "{{ elasticache_valkey_test_name }}" + replication_group: true + engine: valkey + state: absent + tags: + - valkey - name: Make sure Elasticache Subnet group is deleted again elasticache_subnet_group: @@ -189,6 +275,15 @@ ec2_security_group: name: "{{ elasticache_redis_sg_name }}" state: absent + tags: + - redis + + - name: Make sure Valkey Security Group is deleted again + ec2_security_group: + name: "{{ elasticache_valkey_sg_name }}" + state: absent + tags: + - valkey - name: Make sure VPC subnet 1 is deleted again ec2_vpc_subnet: diff --git a/tests/unit/plugins/modules/test_elasticache.py b/tests/unit/plugins/modules/test_elasticache.py new file mode 100644 index 00000000000..33595e87f46 --- /dev/null +++ b/tests/unit/plugins/modules/test_elasticache.py @@ -0,0 +1,77 @@ +# -*- coding: utf-8 -*- + +# Copyright: Contributors to the Ansible project +# GNU General Public License v3.0+ (see COPYING or +# https://www.gnu.org/licenses/gpl-3.0.txt) + +from unittest.mock import MagicMock +from unittest.mock import patch + +import pytest + +from ansible_collections.community.aws.plugins.modules import elasticache + + +def make_replication_group_manager(wait=False): + module = MagicMock() + module.client.return_value = MagicMock() + with patch.object(elasticache.ReplicationGroupManager, "_refresh_data"): + return elasticache.ReplicationGroupManager( + module=module, + name="test-rg", + engine="redis", + cache_engine_version="", + node_type="cache.t3.micro", + num_cache_clusters=1, + cache_port=None, + cache_parameter_group="", + cache_subnet_group="", + cache_security_groups=[], + security_group_ids=[], + replication_group_description="test", + automatic_failover=None, + multi_az_enabled=None, + transit_encryption_enabled=None, + replicas_per_node_group=None, + num_node_groups=None, + cluster_mode=None, + retain_primary_cluster=None, + wait=wait, + ) + + +def test_replication_group_exists_in_snapshotting_status(): + manager = make_replication_group_manager() + manager.status = "snapshotting" + + assert manager.exists() is True + + +def test_replication_group_exists_in_create_failed_status(): + manager = make_replication_group_manager() + manager.status = "create-failed" + + assert manager.exists() is True + + +def test_replication_group_sync_fails_in_create_failed_status(): + manager = make_replication_group_manager() + manager.status = "create-failed" + manager.module.fail_json.side_effect = RuntimeError("fail_json called") + + with pytest.raises(RuntimeError): + manager.sync() + + manager.module.fail_json.assert_called_once_with( + msg="'test-rg' is in create-failed status. Cannot sync. Delete and recreate the replication group." + ) + + +def test_replication_group_create_waits_when_snapshotting(): + manager = make_replication_group_manager(wait=True) + manager.status = "snapshotting" + manager._wait_for_status = MagicMock() + + manager.create() + + manager._wait_for_status.assert_called_once_with("available")