From 80f0bfbed0109ae17c29858dc3d2768e6f669085 Mon Sep 17 00:00:00 2001 From: Olamide Date: Thu, 4 Jun 2026 09:07:08 +0100 Subject: [PATCH 1/3] Add replication-group-secondary module for global datastore members The existing elasticache-redis/replication-group module always configures engine, engine_version, node_type, transit/at-rest encryption, parameter group, snapshots, and an auth token on aws_elasticache_replication_group. When a replication group joins a global datastore via global_replication_group_id, AWS inherits all of those from the primary and the provider's ConflictsWith rules reject them -- even when passed as null, because Terraform still sees the attribute as configured. The node_type=null path also crashed the alarm locals via replace(var.node_type, ...). This adds a dedicated replication-group-secondary module that omits every inherited/conflicting argument and exposes only what a secondary member needs (name, description, global_replication_group_id, subnets, replica count, security groups, alarms). The CloudWatch alarm node-type lookup now reads the node_type back off the created resource instead of a variable, and the random provider / auth-token generation are dropped since the token is inherited. --- .../replication-group-secondary/README.md | 76 +++++++ .../replication-group-secondary/main.tf | 211 ++++++++++++++++++ .../replication-group-secondary/outputs.tf | 19 ++ .../replication-group-secondary/variables.tf | 111 +++++++++ .../replication-group-secondary/versions.tf | 10 + 5 files changed, 427 insertions(+) create mode 100644 elasticache-redis/replication-group-secondary/README.md create mode 100644 elasticache-redis/replication-group-secondary/main.tf create mode 100644 elasticache-redis/replication-group-secondary/outputs.tf create mode 100644 elasticache-redis/replication-group-secondary/variables.tf create mode 100644 elasticache-redis/replication-group-secondary/versions.tf diff --git a/elasticache-redis/replication-group-secondary/README.md b/elasticache-redis/replication-group-secondary/README.md new file mode 100644 index 0000000..20de384 --- /dev/null +++ b/elasticache-redis/replication-group-secondary/README.md @@ -0,0 +1,76 @@ +# ElastiCache Redis (Global Datastore Secondary) + +Provision a secondary (regional) member of an ElastiCache global datastore. + +Use this module instead of `replication-group` when joining an existing global +datastore via `global_replication_group_id`. A secondary member inherits the +engine, engine version, node type, encryption settings, parameter group, +snapshots, and auth token from the global datastore's primary, so those +arguments are intentionally omitted here -- the AWS provider rejects them when +`global_replication_group_id` is set. + + +## Requirements + +| Name | Version | +|------|---------| +| [terraform](#requirement\_terraform) | >= 1.6.2 | +| [aws](#requirement\_aws) | ~> 6.0 | + +## Providers + +| Name | Version | +|------|---------| +| [aws](#provider\_aws) | 6.48.0 | + +## Modules + +| Name | Source | Version | +|------|--------|---------| +| [client\_security\_group](#module\_client\_security\_group) | ../../security-group | n/a | +| [server\_security\_group](#module\_server\_security\_group) | ../../security-group | n/a | + +## Resources + +| Name | Type | +|------|------| +| [aws_cloudwatch_metric_alarm.check_cpu_balance](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_metric_alarm) | resource | +| [aws_cloudwatch_metric_alarm.cpu](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_metric_alarm) | resource | +| [aws_cloudwatch_metric_alarm.memory](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_metric_alarm) | resource | +| [aws_elasticache_replication_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/elasticache_replication_group) | resource | +| [aws_elasticache_subnet_group.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/elasticache_subnet_group) | resource | +| [aws_ec2_instance_type.instance_attributes](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ec2_instance_type) | data source | + +## Inputs + +| Name | Description | Type | Default | Required | +|------|-------------|------|---------|:--------:| +| [alarm\_actions](#input\_alarm\_actions) | SNS topics or other actions to invoke for alarms | `list(object({ arn = string }))` | `[]` | no | +| [allowed\_cidr\_blocks](#input\_allowed\_cidr\_blocks) | CIDR blocks allowed to access the database | `list(string)` | `[]` | no | +| [allowed\_security\_group\_ids](#input\_allowed\_security\_group\_ids) | Security group allowed to access the database | `list(string)` | `[]` | no | +| [apply\_immediately](#input\_apply\_immediately) | Set to true to apply changes immediately | `bool` | `false` | no | +| [client\_security\_group\_name](#input\_client\_security\_group\_name) | Override the name for the security group; defaults to identifer | `string` | `""` | no | +| [create\_client\_security\_group](#input\_create\_client\_security\_group) | Set to false to only use existing security groups | `bool` | `true` | no | +| [create\_server\_security\_group](#input\_create\_server\_security\_group) | Set to false to only use existing security groups | `bool` | `true` | no | +| [description](#input\_description) | Human-readable description for this replication group | `string` | n/a | yes | +| [global\_replication\_group\_id](#input\_global\_replication\_group\_id) | The ID of the global replication group to which this replication group belongs. | `string` | n/a | yes | +| [name](#input\_name) | Name for this cluster | `string` | n/a | yes | +| [port](#input\_port) | Port on which to listen (used for the security group; the replication group itself inherits the port from the global datastore) | `number` | `6379` | no | +| [replica\_count](#input\_replica\_count) | Number of read-only replicas to add to the cluster | `number` | `1` | no | +| [replication\_group\_id](#input\_replication\_group\_id) | Override the ID for the replication group | `string` | `""` | no | +| [server\_security\_group\_ids](#input\_server\_security\_group\_ids) | IDs of VPC security groups for this instance. One of vpc\_id or server\_security\_group\_ids is required | `list(string)` | `[]` | no | +| [server\_security\_group\_name](#input\_server\_security\_group\_name) | Override the name for the security group; defaults to identifer | `string` | `""` | no | +| [subnet\_group\_name](#input\_subnet\_group\_name) | Override the name for the subnet group | `string` | `""` | no | +| [subnet\_ids](#input\_subnet\_ids) | Subnets connected to the database | `list(string)` | n/a | yes | +| [tags](#input\_tags) | Tags to be applied to created resources | `map(string)` | `{}` | no | +| [vpc\_id](#input\_vpc\_id) | ID of VPC for this instance. One of vpc\_id or vpc\_security\_group\_ids is required | `string` | `null` | no | + +## Outputs + +| Name | Description | +|------|-------------| +| [client\_security\_group\_id](#output\_client\_security\_group\_id) | Name of the security group created for clients | +| [id](#output\_id) | ID of the created replication group | +| [instance](#output\_instance) | Elasticache Redis replication group | +| [server\_security\_group\_id](#output\_server\_security\_group\_id) | Name of the security group created for the server | + \ No newline at end of file diff --git a/elasticache-redis/replication-group-secondary/main.tf b/elasticache-redis/replication-group-secondary/main.tf new file mode 100644 index 0000000..5bcc2ac --- /dev/null +++ b/elasticache-redis/replication-group-secondary/main.tf @@ -0,0 +1,211 @@ +# Secondary (regional) member of an ElastiCache global datastore. +# +# When `global_replication_group_id` is set, AWS inherits engine, +# engine_version, node_type, encryption, parameter group, snapshots and the +# auth token from the global datastore's primary. The provider enforces this +# with ConflictsWith rules that fire whenever those attributes are *configured* +# at all -- even when set to null -- so this module simply omits them. +resource "aws_elasticache_replication_group" "this" { + replication_group_id = coalesce(var.replication_group_id, var.name) + global_replication_group_id = var.global_replication_group_id + + apply_immediately = var.apply_immediately + automatic_failover_enabled = local.replica_enabled + description = var.description + multi_az_enabled = local.replica_enabled + num_cache_clusters = local.instance_count + security_group_ids = local.server_security_group_ids + subnet_group_name = aws_elasticache_subnet_group.this.name +} + +resource "aws_elasticache_subnet_group" "this" { + name = coalesce( + var.subnet_group_name, + var.replication_group_id, + var.name + ) + + description = "Redis subnet group" + subnet_ids = var.subnet_ids + + lifecycle { + create_before_destroy = true + } +} + +module "server_security_group" { + count = var.create_server_security_group ? 1 : 0 + source = "../../security-group" + + allowed_cidr_blocks = var.allowed_cidr_blocks + description = "ElastiCache Redis server: ${var.name}" + randomize_name = var.server_security_group_name == "" + tags = var.tags + vpc_id = var.vpc_id + + allowed_security_group_ids = concat( + var.allowed_security_group_ids, + module.client_security_group[*].id + ) + + name = coalesce( + var.server_security_group_name, + "${var.name}-server" + ) + + ports = { + redis = var.port + } +} + +module "client_security_group" { + count = var.create_client_security_group ? 1 : 0 + source = "../../security-group" + + allowed_cidr_blocks = var.allowed_cidr_blocks + allowed_security_group_ids = var.allowed_security_group_ids + description = "ElastiCache Redis client: ${var.name}" + randomize_name = var.client_security_group_name == "" + tags = var.tags + vpc_id = var.vpc_id + + name = coalesce( + var.client_security_group_name, + "${var.name}-client" + ) +} + +resource "aws_cloudwatch_metric_alarm" "cpu" { + count = local.instance_count + + alarm_name = "${var.name}-${count.index}-high-cpu" + alarm_description = "${var.name}-${count.index} is using more than 90% of its CPU" + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = "5" + metric_name = "CPUUtilization" + namespace = "AWS/ElastiCache" + period = "60" + statistic = "Average" + threshold = 90 / data.aws_ec2_instance_type.instance_attributes.default_cores + treat_missing_data = "notBreaching" + + dimensions = { + CacheClusterId = local.instances[count.index] + } + + alarm_actions = var.alarm_actions[*].arn + ok_actions = var.alarm_actions[*].arn +} + +resource "aws_cloudwatch_metric_alarm" "memory" { + count = local.instance_count + + alarm_name = "${var.name}-${count.index}-datababase-memory-remaining" + alarm_description = "${var.name}-${count.index} has less than ${local.memory_threshold_mb}MiB of memory remaining" + comparison_operator = "LessThanOrEqualToThreshold" + evaluation_periods = "2" + metric_name = "FreeableMemory" + namespace = "AWS/ElastiCache" + period = "60" + statistic = "Average" + threshold = local.memory_threshold_mb * 1024 * 1024 + treat_missing_data = "notBreaching" + + dimensions = { + CacheClusterId = local.instances[count.index] + } + + alarm_actions = var.alarm_actions[*].arn + ok_actions = var.alarm_actions[*].arn +} + +resource "aws_cloudwatch_metric_alarm" "check_cpu_balance" { + count = data.aws_ec2_instance_type.instance_attributes.burstable_performance_supported == true ? local.instance_count : 0 + + alarm_name = "${var.name}-${count.index}-elasticache-low-cpu-credit" + alarm_description = "Insufficient CPU credits for ${var.name}-${count.index}" + comparison_operator = "LessThanOrEqualToThreshold" + evaluation_periods = "2" + threshold = "0" + treat_missing_data = "notBreaching" + + alarm_actions = var.alarm_actions[*].arn + ok_actions = var.alarm_actions[*].arn + + metric_query { + id = "e1" + expression = "m1 - m2 - (m3 * 12)" + label = "Available CPU Credits" + return_data = "true" + } + + metric_query { + id = "m1" + + metric { + metric_name = "CPUCreditBalance" + namespace = "AWS/ElastiCache" + period = "120" + stat = "Average" + unit = "Count" + + dimensions = { + CacheClusterId = local.instances[count.index] + } + } + } + + metric_query { + id = "m2" + + metric { + metric_name = "CPUSurplusCreditBalance" + namespace = "AWS/ElastiCache" + period = "120" + stat = "Average" + unit = "Count" + + dimensions = { + CacheClusterId = local.instances[count.index] + } + } + } + + metric_query { + id = "m3" + + metric { + metric_name = "CPUCreditUsage" + namespace = "AWS/ElastiCache" + period = "120" + stat = "Average" + unit = "Count" + + dimensions = { + CacheClusterId = local.instances[count.index] + } + } + } +} + +# The node type is inherited from the global datastore primary, so read it back +# off the created replication group rather than requiring it as an input. +data "aws_ec2_instance_type" "instance_attributes" { + instance_type = local.instance_size +} + +locals { + instance_count = var.replica_count + 1 + instance_size = replace(aws_elasticache_replication_group.this.node_type, "cache.", "") + instances = sort(aws_elasticache_replication_group.this.member_clusters) + owned_security_group_ids = module.server_security_group[*].id + replica_enabled = var.replica_count > 0 + shared_security_group_ids = var.server_security_group_ids + + memory_threshold_mb = data.aws_ec2_instance_type.instance_attributes.memory_size + + server_security_group_ids = concat( + local.owned_security_group_ids, + local.shared_security_group_ids + ) +} diff --git a/elasticache-redis/replication-group-secondary/outputs.tf b/elasticache-redis/replication-group-secondary/outputs.tf new file mode 100644 index 0000000..a31439a --- /dev/null +++ b/elasticache-redis/replication-group-secondary/outputs.tf @@ -0,0 +1,19 @@ +output "client_security_group_id" { + description = "Name of the security group created for clients" + value = join("", module.client_security_group[*].id) +} + +output "instance" { + description = "Elasticache Redis replication group" + value = aws_elasticache_replication_group.this +} + +output "id" { + description = "ID of the created replication group" + value = aws_elasticache_replication_group.this.replication_group_id +} + +output "server_security_group_id" { + description = "Name of the security group created for the server" + value = join("", module.server_security_group[*].id) +} diff --git a/elasticache-redis/replication-group-secondary/variables.tf b/elasticache-redis/replication-group-secondary/variables.tf new file mode 100644 index 0000000..774cbfb --- /dev/null +++ b/elasticache-redis/replication-group-secondary/variables.tf @@ -0,0 +1,111 @@ +variable "name" { + type = string + description = "Name for this cluster" +} + +variable "description" { + description = "Human-readable description for this replication group" + type = string +} + +variable "global_replication_group_id" { + type = string + description = "The ID of the global replication group to which this replication group belongs." +} + +variable "subnet_ids" { + description = "Subnets connected to the database" + type = list(string) +} + +variable "replication_group_id" { + description = "Override the ID for the replication group" + type = string + default = "" +} + +variable "replica_count" { + type = number + default = 1 + description = "Number of read-only replicas to add to the cluster" +} + +variable "port" { + description = "Port on which to listen (used for the security group; the replication group itself inherits the port from the global datastore)" + type = number + default = 6379 +} + +variable "apply_immediately" { + type = bool + description = "Set to true to apply changes immediately" + default = false +} + +variable "subnet_group_name" { + description = "Override the name for the subnet group" + type = string + default = "" +} + +variable "alarm_actions" { + type = list(object({ arn = string })) + description = "SNS topics or other actions to invoke for alarms" + default = [] +} + +variable "tags" { + type = map(string) + description = "Tags to be applied to created resources" + default = {} +} + +# Security group variables + +variable "allowed_cidr_blocks" { + description = "CIDR blocks allowed to access the database" + type = list(string) + default = [] +} + +variable "allowed_security_group_ids" { + description = "Security group allowed to access the database" + type = list(string) + default = [] +} + +variable "client_security_group_name" { + description = "Override the name for the security group; defaults to identifer" + type = string + default = "" +} + +variable "create_client_security_group" { + type = bool + description = "Set to false to only use existing security groups" + default = true +} + +variable "create_server_security_group" { + type = bool + description = "Set to false to only use existing security groups" + default = true +} + +variable "server_security_group_ids" { + type = list(string) + description = "IDs of VPC security groups for this instance. One of vpc_id or server_security_group_ids is required" + default = [] +} + +variable "server_security_group_name" { + description = "Override the name for the security group; defaults to identifer" + type = string + default = "" +} + +variable "vpc_id" { + type = string + description = "ID of VPC for this instance. One of vpc_id or vpc_security_group_ids is required" + default = null +} diff --git a/elasticache-redis/replication-group-secondary/versions.tf b/elasticache-redis/replication-group-secondary/versions.tf new file mode 100644 index 0000000..20568c8 --- /dev/null +++ b/elasticache-redis/replication-group-secondary/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.6.2" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 6.0" + } + } +} From dd256d285a342237a63bcbd22fe68f705eef7722 Mon Sep 17 00:00:00 2001 From: Olamide Date: Thu, 4 Jun 2026 09:11:43 +0100 Subject: [PATCH 2/3] Add terraform workflow for new module --- ...ache-redis-replication-group-secondary.yml | 23 +++++++++++++++++++ 1 file changed, 23 insertions(+) create mode 100644 .github/workflows/elasticache-redis-replication-group-secondary.yml diff --git a/.github/workflows/elasticache-redis-replication-group-secondary.yml b/.github/workflows/elasticache-redis-replication-group-secondary.yml new file mode 100644 index 0000000..44a9cf6 --- /dev/null +++ b/.github/workflows/elasticache-redis-replication-group-secondary.yml @@ -0,0 +1,23 @@ +name: elasticache-redis/replication-group-secondary +on: + pull_request: + branches: + - main + paths: + - elasticache-redis/replication-group-secondary/** + types: + - closed + - opened + - reopened + - synchronize +jobs: + terraform: + uses: ./.github/workflows/terraform.yml + concurrency: ${{ github.workflow }} + with: + module: elasticache-redis/replication-group-secondary + permissions: + id-token: write + contents: write + checks: write + pull-requests: write From c5b856a08d8704f10f18e11752f1477398324283 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" Date: Thu, 4 Jun 2026 08:12:22 +0000 Subject: [PATCH 3/3] terraform-docs: automated action --- elasticache-redis/replication-group-secondary/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/elasticache-redis/replication-group-secondary/README.md b/elasticache-redis/replication-group-secondary/README.md index 20de384..6ce2394 100644 --- a/elasticache-redis/replication-group-secondary/README.md +++ b/elasticache-redis/replication-group-secondary/README.md @@ -21,7 +21,7 @@ arguments are intentionally omitted here -- the AWS provider rejects them when | Name | Version | |------|---------| -| [aws](#provider\_aws) | 6.48.0 | +| [aws](#provider\_aws) | ~> 6.0 | ## Modules