From 10be9d6913044d9d254244cd9afa3b5c75d88084 Mon Sep 17 00:00:00 2001 From: Johno Crawford Date: Thu, 22 Jan 2026 13:40:32 +0100 Subject: [PATCH 1/6] feat(elasticache): add valkey support via replication groups --- .../20260122-elasticache-valkey.yaml | 3 + plugins/modules/elasticache.py | 441 +++++++++++++++++- .../modules/elasticache_parameter_group.py | 20 +- .../targets/elasticache/defaults/main.yml | 2 + .../targets/elasticache/tasks/main.yml | 94 ++++ 5 files changed, 541 insertions(+), 19 deletions(-) create mode 100644 changelogs/fragments/20260122-elasticache-valkey.yaml diff --git a/changelogs/fragments/20260122-elasticache-valkey.yaml b/changelogs/fragments/20260122-elasticache-valkey.yaml new file mode 100644 index 00000000000..0b3e43f3714 --- /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. + - elasticache_parameter_group - add Valkey and newer cache parameter group families to the documented choices. diff --git a/plugins/modules/elasticache.py b/plugins/modules/elasticache.py index fd823fcdda7..729f9e8db99 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,56 @@ - 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 + replication_group_description: + description: + - Description for the replication group. + - Required when I(replication_group=true) and I(state=present). + type: str + num_cache_clusters: + description: + - The number of cache clusters in the replication group. + - Only used when I(replication_group=true). + type: int + 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 + num_node_groups: + description: + - The number of node groups (shards) for the replication group. + - Only used when I(replication_group=true). + type: int + automatic_failover: + description: + - Whether automatic failover is enabled for the replication group. + - Only used when I(replication_group=true). + type: bool + multi_az_enabled: + description: + - Whether Multi-AZ is enabled for the replication group. + - Only used when I(replication_group=true). + type: bool + transit_encryption_enabled: + description: + - Whether in-transit encryption is enabled for the replication group. + - Only used when I(replication_group=true). + type: bool + 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 + retain_primary_cluster: + description: + - Whether to retain the primary cluster when deleting a replication group. + - Only used when I(replication_group=true). + type: bool cache_port: description: - The port number on which each of the cache nodes will accept @@ -128,6 +179,16 @@ 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_description: "Valkey test replication group" + engine: valkey + node_type: cache.t3.micro + num_cache_clusters: 1 """ from time import sleep @@ -473,6 +534,291 @@ def _get_nodes_to_remove(self): return cache_node_ids[-num_nodes_to_remove:] +class ReplicationGroupManager: + """Handles elasticache replication group creation and destruction""" + + EXIST_STATUSES = ["available", "creating", "modifying", "deleting"] + + 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 ["creating", "modifying"]: + if self.wait: + self._wait_for_status("available") + return + 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 ["creating", "modifying"]: + 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 in ["creating", "modifying"]: + 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", "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 +828,16 @@ 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_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 +867,78 @@ 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_description = module.params["replication_group_description"] + num_cache_clusters = module.params["num_cache_clusters"] + replicas_per_node_group = module.params["replicas_per_node_group"] + num_node_groups = module.params["num_node_groups"] + automatic_failover = module.params["automatic_failover"] + multi_az_enabled = module.params["multi_az_enabled"] + transit_encryption_enabled = module.params["transit_encryption_enabled"] + cluster_mode = module.params["cluster_mode"] + retain_primary_cluster = module.params["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_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 'num_cache_clusters' or '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..d68b6e8d9fd 100644 --- a/plugins/modules/elasticache_parameter_group.py +++ b/plugins/modules/elasticache_parameter_group.py @@ -20,7 +20,21 @@ 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'] + 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 +308,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..35f446b008e 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,52 @@ - '"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_description: "Valkey replication group for integration tests" + engine: valkey + node_type: cache.t3.micro + cache_port: "{{ elasticache_redis_port }}" + cache_subnet_group: "{{ elasticache_subnet_group_name }}" + num_cache_clusters: 1 + transit_encryption_enabled: false + 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 +253,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 +274,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: From 44337776b43bad3b180030c70f6eb3254f67a93a Mon Sep 17 00:00:00 2001 From: Johno Crawford Date: Thu, 12 Feb 2026 08:32:07 +0100 Subject: [PATCH 2/6] handle replication group snapshotting/create-failed states --- plugins/modules/elasticache.py | 23 ++++-- .../unit/plugins/modules/test_elasticache.py | 77 +++++++++++++++++++ 2 files changed, 95 insertions(+), 5 deletions(-) create mode 100644 tests/unit/plugins/modules/test_elasticache.py diff --git a/plugins/modules/elasticache.py b/plugins/modules/elasticache.py index 729f9e8db99..303de8f6e01 100644 --- a/plugins/modules/elasticache.py +++ b/plugins/modules/elasticache.py @@ -537,7 +537,11 @@ def _get_nodes_to_remove(self): class ReplicationGroupManager: """Handles elasticache replication group creation and destruction""" - EXIST_STATUSES = ["available", "creating", "modifying", "deleting"] + # 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, @@ -612,10 +616,14 @@ def create(self): """Create an ElastiCache replication group""" if self.status == "available": return - if self.status in ["creating", "modifying"]: + 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") @@ -674,7 +682,7 @@ def delete(self): if self.wait: self._wait_for_status("gone") return - if self.status in ["creating", "modifying"]: + if self.status in self.IN_PROGRESS_STATUSES: if self.wait: self._wait_for_status("available") else: @@ -701,7 +709,12 @@ def sync(self): if not self.exists(): self.module.fail_json(msg=f"'{self.name}' is {self.status}. Cannot sync.") - if self.status in ["creating", "modifying"]: + 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: @@ -747,7 +760,7 @@ def get_info(self): def _wait_for_status(self, awaited_status): """Wait for status to change from present status to awaited_status""" - status_map = {"creating": "available", "modifying": "available", "deleting": "gone"} + status_map = {"creating": "available", "modifying": "available", "snapshotting": "available", "deleting": "gone"} if self.status == awaited_status: return if status_map[self.status] != awaited_status: 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") From b8d0ffd0e0186403b5dccf0a4bf07f6b3d7a8913 Mon Sep 17 00:00:00 2001 From: Johno Crawford Date: Mon, 16 Feb 2026 16:25:35 +0100 Subject: [PATCH 3/6] Update changelogs/fragments/20260122-elasticache-valkey.yaml Co-authored-by: Bikouo Aubin <79859644+abikouo@users.noreply.github.com> --- changelogs/fragments/20260122-elasticache-valkey.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelogs/fragments/20260122-elasticache-valkey.yaml b/changelogs/fragments/20260122-elasticache-valkey.yaml index 0b3e43f3714..5fa2c5b7056 100644 --- a/changelogs/fragments/20260122-elasticache-valkey.yaml +++ b/changelogs/fragments/20260122-elasticache-valkey.yaml @@ -1,3 +1,3 @@ minor_changes: - - elasticache - add replication group support (including Valkey engines) for create/modify/delete workflows. + - elasticache - add replication group support (including Valkey engines) for create/modify/delete workflows (https://github.com/ansible-collections/community.aws/pull/2386). - elasticache_parameter_group - add Valkey and newer cache parameter group families to the documented choices. From 0c7df5a596a676a47f832e53619e29760d84fe37 Mon Sep 17 00:00:00 2001 From: Johno Crawford Date: Mon, 16 Feb 2026 16:25:47 +0100 Subject: [PATCH 4/6] Update changelogs/fragments/20260122-elasticache-valkey.yaml Co-authored-by: Bikouo Aubin <79859644+abikouo@users.noreply.github.com> --- changelogs/fragments/20260122-elasticache-valkey.yaml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/changelogs/fragments/20260122-elasticache-valkey.yaml b/changelogs/fragments/20260122-elasticache-valkey.yaml index 5fa2c5b7056..e66bb1c211e 100644 --- a/changelogs/fragments/20260122-elasticache-valkey.yaml +++ b/changelogs/fragments/20260122-elasticache-valkey.yaml @@ -1,3 +1,3 @@ minor_changes: - elasticache - add replication group support (including Valkey engines) for create/modify/delete workflows (https://github.com/ansible-collections/community.aws/pull/2386). - - elasticache_parameter_group - add Valkey and newer cache parameter group families to the documented choices. + - elasticache_parameter_group - add Valkey and newer cache parameter group families to the documented choices (https://github.com/ansible-collections/community.aws/pull/2386). From 8f110091b127cbe802142916215a9edef38a5a8c Mon Sep 17 00:00:00 2001 From: Johno Crawford Date: Mon, 16 Feb 2026 17:22:38 +0100 Subject: [PATCH 5/6] address review feedback for replication group options and docs --- .../20260122-elasticache-valkey.yaml | 2 +- plugins/modules/elasticache.py | 163 +++++++++++------- .../modules/elasticache_parameter_group.py | 1 + .../targets/elasticache/tasks/main.yml | 7 +- 4 files changed, 103 insertions(+), 70 deletions(-) diff --git a/changelogs/fragments/20260122-elasticache-valkey.yaml b/changelogs/fragments/20260122-elasticache-valkey.yaml index e66bb1c211e..9fb7c25df0e 100644 --- a/changelogs/fragments/20260122-elasticache-valkey.yaml +++ b/changelogs/fragments/20260122-elasticache-valkey.yaml @@ -1,3 +1,3 @@ minor_changes: - - elasticache - add replication group support (including Valkey engines) for create/modify/delete workflows (https://github.com/ansible-collections/community.aws/pull/2386). + - 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 303de8f6e01..dda2cdeac93 100644 --- a/plugins/modules/elasticache.py +++ b/plugins/modules/elasticache.py @@ -55,51 +55,67 @@ - Whether to manage a replication group instead of a cache cluster. type: bool default: false - replication_group_description: + version_added: 10.1.0 + replication_group_options: description: - - Description for the replication group. - - Required when I(replication_group=true) and I(state=present). - type: str - num_cache_clusters: - description: - - The number of cache clusters in the replication group. - - Only used when I(replication_group=true). - type: int - 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 - num_node_groups: - description: - - The number of node groups (shards) for the replication group. - - Only used when I(replication_group=true). - type: int - automatic_failover: - description: - - Whether automatic failover is enabled for the replication group. - - Only used when I(replication_group=true). - type: bool - multi_az_enabled: - description: - - Whether Multi-AZ is enabled for the replication group. - - Only used when I(replication_group=true). - type: bool - transit_encryption_enabled: - description: - - Whether in-transit encryption is enabled for the replication group. - - Only used when I(replication_group=true). - type: bool - 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 - retain_primary_cluster: - description: - - Whether to retain the primary cluster when deleting a replication group. - - Only used when I(replication_group=true). - type: bool + - 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 @@ -185,10 +201,11 @@ name: "test-valkey-rg" state: present replication_group: true - replication_group_description: "Valkey test replication group" + replication_group_options: + replication_group_description: "Valkey test replication group" + num_cache_clusters: 1 engine: valkey node_type: cache.t3.micro - num_cache_clusters: 1 """ from time import sleep @@ -842,15 +859,20 @@ def main(): node_type=dict(default="cache.t2.small"), num_nodes=dict(default=1, type="int"), replication_group=dict(default=False, type="bool"), - 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"), + 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"), @@ -881,15 +903,16 @@ def main(): hard_modify = module.params["hard_modify"] cache_parameter_group = module.params["cache_parameter_group"] replication_group = module.params["replication_group"] - replication_group_description = module.params["replication_group_description"] - num_cache_clusters = module.params["num_cache_clusters"] - replicas_per_node_group = module.params["replicas_per_node_group"] - num_node_groups = module.params["num_node_groups"] - automatic_failover = module.params["automatic_failover"] - multi_az_enabled = module.params["multi_az_enabled"] - transit_encryption_enabled = module.params["transit_encryption_enabled"] - cluster_mode = module.params["cluster_mode"] - retain_primary_cluster = module.params["retain_primary_cluster"] + 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") @@ -900,11 +923,19 @@ def main(): 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_description' is required when replication_group is true") + 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 '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'") diff --git a/plugins/modules/elasticache_parameter_group.py b/plugins/modules/elasticache_parameter_group.py index d68b6e8d9fd..8eec487c85e 100644 --- a/plugins/modules/elasticache_parameter_group.py +++ b/plugins/modules/elasticache_parameter_group.py @@ -20,6 +20,7 @@ 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. + - The values C(memcached1.6), C(valkey7), and C(valkey8) were added in release C(10.1.0). choices: [ 'memcached1.4', diff --git a/tests/integration/targets/elasticache/tasks/main.yml b/tests/integration/targets/elasticache/tasks/main.yml index 35f446b008e..cfc04a6350c 100644 --- a/tests/integration/targets/elasticache/tasks/main.yml +++ b/tests/integration/targets/elasticache/tasks/main.yml @@ -218,13 +218,14 @@ elasticache: name: "{{ elasticache_valkey_test_name }}" replication_group: true - replication_group_description: "Valkey replication group for integration tests" + 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 }}" - num_cache_clusters: 1 - transit_encryption_enabled: false security_group_ids: - "{{ elasticache_valkey_sg.group_id }}" state: present From a3ef2daf531f2c06ed10246644915879bebb41b5 Mon Sep 17 00:00:00 2001 From: Mark Chappell Date: Mon, 13 Apr 2026 12:23:11 +0200 Subject: [PATCH 6/6] update released --- plugins/modules/elasticache_parameter_group.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/plugins/modules/elasticache_parameter_group.py b/plugins/modules/elasticache_parameter_group.py index 8eec487c85e..505f249d0e4 100644 --- a/plugins/modules/elasticache_parameter_group.py +++ b/plugins/modules/elasticache_parameter_group.py @@ -20,7 +20,7 @@ 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. - - The values C(memcached1.6), C(valkey7), and C(valkey8) were added in release C(10.1.0). + - The values C(memcached1.6), C(valkey7), and C(valkey8) were added in release C(11.1.0). choices: [ 'memcached1.4',