From 3fd861a5fe76ba33e4570fd76b55ea11634c610d Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 04:05:07 +0100 Subject: [PATCH 1/6] feat(elasticache)!: refactor ElastiCache module to support multiple deployment modes (replication_group, cluster, serverless) - Updated locals.tf to define deployment modes and naming conventions. - Enhanced main.tf to configure ElastiCache resources based on deployment mode. - Added outputs for replication group, cluster, and serverless cache details. - Removed deprecated readme.md and updated variables.tf with comprehensive input descriptions. - Improved validation for engine types, deployment modes, and snapshot configurations. - Adjusted versions.tf to require compatible provider versions. BREAKING CHANGE: Replaces exisiting module, replaces all explicit and implicit resources and changes upstream module version. All resources will be re-created. --- .../modules/elasticache/.terraform.lock.hcl | 69 ++- infrastructure/modules/elasticache/README.md | 454 ++++++++++++++++++ infrastructure/modules/elasticache/context.tf | 305 ++++++++++++ infrastructure/modules/elasticache/locals.tf | 30 +- infrastructure/modules/elasticache/main.tf | 285 +++++++---- infrastructure/modules/elasticache/outputs.tf | 126 ++++- infrastructure/modules/elasticache/readme.md | 70 --- .../modules/elasticache/variables.tf | 363 ++++++++++++-- .../modules/elasticache/versions.tf | 8 +- 9 files changed, 1459 insertions(+), 251 deletions(-) create mode 100644 infrastructure/modules/elasticache/README.md create mode 100644 infrastructure/modules/elasticache/context.tf delete mode 100644 infrastructure/modules/elasticache/readme.md diff --git a/infrastructure/modules/elasticache/.terraform.lock.hcl b/infrastructure/modules/elasticache/.terraform.lock.hcl index 89639620..11dee3c2 100644 --- a/infrastructure/modules/elasticache/.terraform.lock.hcl +++ b/infrastructure/modules/elasticache/.terraform.lock.hcl @@ -2,29 +2,54 @@ # Manual edits may be lost in future updates. provider "registry.terraform.io/hashicorp/aws" { - version = "6.50.0" - constraints = ">= 6.42.0" + version = "6.51.0" + constraints = ">= 5.73.0, >= 5.93.0, >= 6.14.0" hashes = [ - "h1:8y10QFtGLHl3pF/R1/hO7VCPHTexm1whc0BfuG4uruw=", - "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", - "h1:Uf2LlEibaBdksEUkOoiQbzEbkIgOR6tUE/0tCd36Xzk=", - "h1:gnyVeH3L2erQ/di0a4x5i0AlsIcdLjyK5+Vmbf3qyck=", - "h1:mNg4vBXXqbO0hY2jCxhOyKVrnjEO0viTG2EY4oAlWaQ=", - "zh:0072806bb262c6d86bc25b4a75750e469881144c14818afdba7b82db840e1588", - "zh:1ebc2dae335dad7a8b16a1985b69a63a14954282bb44fdba7d5103f77551ac7b", - "zh:2dab48fe8f3193b8216d578ac1e3674fa566435cc7dbce2953d55b72e31d0241", - "zh:2fc3d3029c2b7429472391ef339672e1fca8e6ff32c8a519bf3acedafa7e24fe", - "zh:38a36e64e7212f6cedac861ea4d449cce07131b3378de601bf9d49a99e000208", - "zh:3ac70758ed251ce78b7f541a5a79cc6fe56474412783ae1decef719bdd0f30bf", - "zh:4385d3903e685bddb2b8005b4eb7db89f030267d4d03c7d792d2f5e739cc874a", - "zh:4cce0760b87fbafd51f30faec2a737f4183b7c615f4a86557f7d3c893a610dc5", - "zh:4feaeed18694239b896c6415d9a1e5ef89e1da4f4ad60924aa0522adeb1f6599", - "zh:502fca2be1c95f443c3e67d0555601d1de65b4ca82d197c059e9c868360e3a0a", - "zh:57d037f6fdd045f2660909c3bdface9622d81165ce647479cba98d1f353c5eab", - "zh:5dc5a0b915c2ac5256d909458f5c8e40b35f78b3a36ea893c86624eaf6c54e37", + "h1:017ISHZZBI+yeqA4AAtgLQJC7Lhd4wYM7tEKYmlk/7Y=", + "h1:4c8zjgtGH0QgP+p/cF1UqdqkvD7V5i0ZxqslieZLTbc=", + "h1:QWxF+1ePJ4qFCHEc6PyHNeXc865wLvrWVl71d/nABa8=", + "h1:aPBmqoiYqfrIgCGwzuemljkOXuGCYQRTXo91nQxrE+s=", + "h1:bclp+xS1fYeOCil0XZO6mKvEeHFESt5K/XotVSZND54=", + "zh:03fcea0a1ea2ca81d62d4d2e2961181bef9068b1c701f2cddc4aa5fac105818a", + "zh:1213944cd623143974ea5c9b70b22ae1ccca33d743924c149ed089d34b8e08b4", + "zh:190a46da0c69082b74da48238ce134d2fc9893e09122ac249c5689f88eab7e13", + "zh:1b312a4b53fa3cf731f95e674c033865feea5455f163b86136f2614424637293", + "zh:2b319814806222c5aba196b1a78756a6b36dc5c91f85edda349234d8a2f20a6a", + "zh:2bddf92c8efc6ad445a2eb8a0e5f88742a0596392c3a4ebc350ebb4105a4a96d", + "zh:3bef0c4f675c09034ff017cf899977b1765b2c0b3d1e489bcb06a5fcac316e2d", + "zh:47c46b5aa22199638fed5c93b195bbfd1182a1408edad4e5c39d4a73a04493f6", + "zh:5f808699650f6db961964466c77f5a581eab142a91c2e54810bb09b6f2fcd3f2", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", - "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", - "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", + "zh:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = ">= 3.0.0" + hashes = [ + "h1:OO+IuvQJSPmWdN8AyyIEvPJbLvDQpgX/zbktoa9KsJE=", + "h1:UlBuNVuCGJ39tTv2c5gz2NRZnQbXfbIWbTzWcth5o74=", + "h1:lVDv+0AjDjrLfpmaJbWqUmIw/k3/AHXLc3N4m55SNdo=", + "h1:o0s5Mk9NXMP60nlheO1r0LsDGGratFb3oL0t7bD2QnM=", + "h1:q/uaUTBdKgAmZESrwsoeDQff9uUA/cI/N5ZKNgVwa9c=", + "zh:161ad0bd9a75768c82f53fb6e7172a9d8be2d4889b012645a34795031aaf1bf1", + "zh:19dc9a5b17729725ccfc4f45b0500af0ee5bc6b6b160c7adb8f2bf617d2c80ea", + "zh:269eda8fe42daa7974d5a34d166c3ba9defe80cde86c01e4dadcfdf2e1f05e5f", + "zh:373f7c65566f8f2cc7f45d698654feb9d988996957e1266a69ca00c52d6d16d0", + "zh:5599d16804c41c83009ec621b6d6b6f74e102f5827678a4750f8809055546b61", + "zh:583be0440469a22bff70dcfa56593b01566860b29607437264adb51060cf46fc", + "zh:5f211d8ec3f2e1f414870d9584bfe26e6995560ef81c748f8447a48164767398", + "zh:78d5eefdd9e494defcb3c68d282b8f96630502cac21d1ea161f53cfe9bb483b3", + "zh:7b547fd16216761ef86efc3ed516ac5ac0c5c42b7c7eb24a08cef2d93f69ed5e", + "zh:7e7c0679daf2a382151d05068c8c3f0dae6b7b7dccf818827b73dd08638df2ef", + "zh:8089dec888a8038b9b4fb23b3df7e1057293dbc5b60b42cc47ff690d69d4b61b", + "zh:c51f15a031edfd6f23ce8ced3446ca7f8d8d647e2499890d7d5d10d5016d7257", + "zh:c94784f005708890dc6895afd53636ec00ec1e430b15d41e5aebfb1d4b39bd04", ] } diff --git a/infrastructure/modules/elasticache/README.md b/infrastructure/modules/elasticache/README.md new file mode 100644 index 00000000..f1be64f7 --- /dev/null +++ b/infrastructure/modules/elasticache/README.md @@ -0,0 +1,454 @@ +# ElastiCache + +NHS Screening wrapper around the community +[`terraform-aws-modules/elasticache/aws`](https://registry.terraform.io/modules/terraform-aws-modules/elasticache/aws/latest) +module that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. + +Supports **Valkey** (recommended), **Redis** (with cluster mode and replication), and **Memcached** engines with configurable high-availability options for session storage and caching workloads. + +## What this module enforces + +|Control|How it is enforced| +|---|---| +|Encryption in transit (TLS)|`transit_encryption_enabled = true` enforced; `auth_token` required for Redis/Valkey| +|Encryption at rest|`at_rest_encryption_enabled = true` enforced; KMS key configurable| +|No public access|Deployed in private subnets; security groups restrict ingress to specified sources| +|Multi-AZ availability|`multi_az_enabled = true` by default; `automatic_failover_enabled = true` by default| +|High availability (Redis/Valkey)|Cluster mode with configurable replicas per shard; all nodes store full dataset| +|Logging|Engine logs and slow logs delivered to CloudWatch with configurable retention| +|Backup & snapshots|Configurable retention (default 5 days); final snapshot on deletion| +|Maintenance windows|Configurable UTC window; updates applied during window (not immediately)| +|All resources tagged|Via `module.this.tags`; naming derived from context module| + +## Supported Engines + +|Engine|Versions|Cluster Mode|Serverless|Failover|Recommended Use| +|---|---|---|---|---|---| +|**Valkey** (recommended)|7.2, 8.0|✓ Sharded|✓|Automatic|Primary choice; Redis-compatible; better governance| +|**Redis**|7.0, 7.1, 7.2, 6.x|✓ Sharded|✓|Automatic|Legacy deployments; same features as Valkey| +|**Memcached**|1.6.x, 1.7.x|✗ Single node|✗|None|Stateless caching; not recommended for session storage| + +## Node Types + +- **t3/t4g (burstable)**: `cache.t3.small`, `cache.t4g.medium` — development, low-traffic +- **r6g/r7g (memory-optimized)**: `cache.r6g.large`, `cache.r7g.xlarge` — production, steady-state +- **m6g/m7g (general)**: `cache.m6g.large`, `cache.m7g.large` — balanced price/performance +- **r6gd/r7gd (with local NVMe)**: Enable data tiering for cost savings (Redis 6.0+) + +## Usage + +### 1. Valkey — replication group, cluster mode (production default) + +Recommended starting point for session storage. Multi-AZ, 1 shard with 2 replicas. +Security group supplied externally from the `security-group` module. + +```hcl +module "session_cache" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/elasticache?ref=main" + + service = "bcss" + project = "platform" + environment = "prod" + name = "session" + + deployment_mode = "replication_group" + engine = "valkey" + engine_version = "8.0" + node_type = "cache.r7g.large" + + cluster_mode_enabled = true + num_node_groups = 1 + replicas_per_node_group = 2 + multi_az_enabled = true + + subnet_ids = module.vpc.private_subnet_ids + create_security_group = false + security_group_ids = [module.cache_sg.id] + + auth_token = data.aws_secretsmanager_secret_version.cache_auth.secret_string + kms_key_arn = module.cache_kms.key_arn + + notification_topic_arn = module.alerting.topic_arn +} +``` + +### 2. Valkey — standalone cluster (non-production cost saving) + +Single-node cluster for dev/test environments where HA is not required. + +```hcl +module "session_cache_dev" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/elasticache?ref=main" + + service = "bcss" + project = "platform" + environment = "dev" + name = "session" + + deployment_mode = "cluster" + engine = "valkey" + engine_version = "8.0" + node_type = "cache.t4g.small" + num_cache_nodes = 1 + + subnet_ids = module.vpc.private_subnet_ids + create_security_group = false + security_group_ids = [module.cache_sg.id] + + auth_token = data.aws_secretsmanager_secret_version.cache_auth.secret_string + + # Reduce snapshot retention for non-prod + snapshot_retention_days = 1 +} +``` + +### 3. Valkey — serverless (auto-scaling, no node provisioning) + +No capacity planning required; scales on demand. No `node_type` needed. + +```hcl +module "session_cache_serverless" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/elasticache?ref=main" + + service = "bcss" + project = "reports" + environment = "prod" + name = "cache" + + deployment_mode = "serverless" + engine = "valkey" + engine_version = "8.0" + + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [module.cache_sg.id] + + kms_key_arn = module.cache_kms.key_arn + + # Optional hard limits; omit for fully elastic scaling + serverless_cache_usage_limits = { + data_storage = { maximum = 50, unit = "GB" } + ecpu_per_second = { maximum = 5000 } + } +} +``` + +### 4. Redis — replication group, simple replication (no sharding) + +Single primary with replicas; no cluster mode. Suitable for workloads that need +replication but not horizontal key-space sharding. + +```hcl +module "content_cache" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/elasticache?ref=main" + + service = "bcss" + project = "web" + environment = "prod" + name = "content" + + deployment_mode = "replication_group" + engine = "redis" + engine_version = "7.2" + node_type = "cache.r7g.large" + cluster_mode_enabled = false + num_cache_nodes = 3 # 1 primary + 2 replicas + multi_az_enabled = true + + subnet_ids = module.vpc.private_subnet_ids + create_security_group = false + security_group_ids = [module.cache_sg.id] + + kms_key_arn = module.cache_kms.key_arn + auth_token = data.aws_secretsmanager_secret_version.cache_auth.secret_string + + snapshot_window = "02:00-03:00" + snapshot_retention_days = 14 + notification_topic_arn = module.alerting.topic_arn + maintenance_window = "sun:03:00-sun:04:00" +} +``` + +### 5. Redis — replication group, cluster mode with multiple shards + +Horizontally sharded for large datasets or high throughput. +Uses data tiering on r7gd instances for cost-efficient cold-data storage. + +```hcl +module "large_cache" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/elasticache?ref=main" + + service = "bcss" + project = "platform" + environment = "prod" + name = "large" + + deployment_mode = "replication_group" + engine = "redis" + engine_version = "7.2" + node_type = "cache.r7gd.xlarge" + cluster_mode_enabled = true + num_node_groups = 3 + replicas_per_node_group = 2 + multi_az_enabled = true + data_tiering_enabled = true + + subnet_ids = module.vpc.private_subnet_ids + create_security_group = false + security_group_ids = [module.cache_sg.id] + + kms_key_arn = module.cache_kms.key_arn + auth_token = data.aws_secretsmanager_secret_version.cache_auth.secret_string + + snapshot_retention_days = 7 + notification_topic_arn = module.alerting.topic_arn +} +``` + +### 6. Redis — serverless + +```hcl +module "redis_serverless" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/elasticache?ref=main" + + service = "bcss" + project = "api" + environment = "prod" + name = "rate-limit" + + deployment_mode = "serverless" + engine = "redis" + engine_version = "7.2" + + subnet_ids = module.vpc.private_subnet_ids + security_group_ids = [module.cache_sg.id] + + kms_key_arn = module.cache_kms.key_arn +} +``` + +### 7. Memcached — cross-AZ cluster (stateless page caching) + +Memcached only supports `deployment_mode = "cluster"`. No replication, no snapshots. +Use `az_mode = "cross-az"` with `num_cache_nodes >= 2` for redundancy. + +```hcl +module "page_cache" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/elasticache?ref=main" + + service = "bcss" + project = "web" + environment = "prod" + name = "page-cache" + + deployment_mode = "cluster" + engine = "memcached" + engine_version = "1.6.22" + node_type = "cache.t4g.medium" + num_cache_nodes = 2 + az_mode = "cross-az" + + subnet_ids = module.vpc.private_subnet_ids + create_security_group = false + security_group_ids = [module.cache_sg.id] + + # No auth_token, kms_key_arn, or snapshots for Memcached +} +``` + +## Conventions + +- **Deployment mode**: `replication_group` (default) for production HA; `cluster` for non-prod single/multi-node; `serverless` for auto-scaling with no node management. +- **Naming**: Resource names are derived from `module.this.id`. Use `attributes` and `tags` to differentiate multiple instances. +- **Security groups**: Pass existing SG IDs via `security_group_ids` (from the `security-group` module). Set `create_security_group = true` with `security_group_rules` to create one inline. +- **KMS**: Pass `kms_key_arn` from the `kms` module for customer-managed encryption. Omit for AWS-managed (still enforced at rest). +- **Encryption**: Both in-transit (TLS) and at-rest encryption are **enforced and cannot be disabled**. Auth tokens required for Redis/Valkey. +- **Cluster mode**: Enabled by default within `replication_group` mode. Each shard holds the full dataset; scales out by adding shards. +- **Replicas**: Default 2 per shard. Set `replicas_per_node_group = 0` only for non-critical single-node deployments. +- **Logging**: Both slow-log and engine-log delivered to CloudWatch with 365-day retention by default. Override via `log_delivery_configuration`. Pre-create log groups via the cloudwatch module for KMS encryption. +- **Snapshots**: Redis/Valkey only (not Memcached). Default 5-day retention. Set `final_snapshot_identifier_prefix` to preserve state on deletion. +- **Maintenance**: Applied during the configured window (default: Sunday 03:00–05:00 UTC). Use `apply_immediately = true` for emergency patches only. + +## What this module does NOT do + +- Create KMS keys. Use the `kms` module and pass the ARN via `kms_key_arn`. +- Create security groups. Use the `security-group` module and pass the ID via `security_group_ids`, or set `create_security_group = true` to create one inline via the upstream module. +- Create CloudWatch log groups with custom KMS encryption. Pass pre-created group names via `log_delivery_configuration`. See the TODO comment in [main.tf](main.tf) for the pattern. +- Manage EC2 instances or ECS task definitions; point them at the cache endpoint outputs. +- Configure client-side replica read preference. That is an application concern. +- Perform failover testing or validate backup/restore procedures. Validate separately in lower environments. +- Manage DNS aliases or application-level caching strategies. +- Auto-scale server-full deployments based on memory pressure. Monitor CloudWatch metrics and resize manually. + +## Security Checklist + +Before deploying, verify: + +- [x] Encryption in transit enforced (TLS) +- [x] Encryption at rest enforced +- [x] No public access (private subnets + security groups) +- [x] Multi-AZ enabled for production +- [x] Automatic failover enabled for HA +- [x] Auth token set for Redis/Valkey (stored in Secrets Manager) +- [x] Allowed security groups/CIDRs explicitly defined +- [x] Logging enabled with KMS encryption +- [x] Snapshots enabled with retention policy +- [x] Maintenance window set to low-traffic time +- [x] SNS topic for notifications configured +- [x] All resources tagged via context module + +## Outputs + +|Output|Mode|Description| +|---|---|---| +|`id`|all|Active deployment mode| +|`replication_group_id`|replication_group|Replication group ID| +|`replication_group_arn`|replication_group|Replication group ARN| +|`primary_endpoint_address`|replication_group|Writer endpoint| +|`reader_endpoint_address`|replication_group|Read-only endpoint| +|`configuration_endpoint_address`|replication_group|Cluster-mode shard endpoint| +|`member_clusters`|replication_group|List of member node IDs| +|`port`|replication_group|Listening port| +|`cluster_arn`|cluster|Standalone cluster ARN| +|`cluster_address`|cluster|Cluster DNS name / primary endpoint| +|`cluster_configuration_endpoint`|cluster|Memcached auto-discovery endpoint| +|`serverless_arn`|serverless|Serverless cache ARN| +|`serverless_endpoint`|serverless|Serverless connection endpoint| +|`serverless_reader_endpoint`|serverless|Serverless reader endpoint| +|`security_group_id`|all|First caller-managed SG ID| +|`cloudwatch_log_group_name`|replication_group, cluster|Primary log group name| +|`cloudwatch_log_group_arn`|replication_group, cluster|Primary log group ARN| +|`snapshot_window`|all|Snapshot window| +|`maintenance_window`|all|Maintenance window| + +## Exemplar Modules + +Reference these for patterns: + +- [s3-bucket](../s3-bucket) — full wrapper with comprehensive security +- [kms](../kms) — customer-managed key creation +- [security-group](../security-group) — security group patterns +- [sns](../sns) — notification topic setup + +## Terraform Validation + +```bash +# Format +terraform fmt -recursive infrastructure/modules/elasticache + +# Initialize (required for validate) +terraform -chdir=infrastructure/modules/elasticache init + +# Validate +terraform -chdir=infrastructure/modules/elasticache validate +``` + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.0 | +| [aws](#requirement\_aws) | >= 5.93 | +| [random](#requirement\_random) | >= 3.0 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [elasticache](#module\_elasticache) | terraform-aws-modules/elasticache/aws | 1.11.0 | +| [elasticache\_serverless](#module\_elasticache\_serverless) | terraform-aws-modules/elasticache/aws//modules/serverless-cache | 1.11.0 | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | +| [apply\_immediately](#input\_apply\_immediately) | Apply parameter changes immediately instead of during the maintenance window. Default: false (safer, batches changes). | `bool` | `false` | no | +| [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | +| [auth\_token](#input\_auth\_token) | Authentication token for Redis/Valkey clusters (16-128 characters, alphanumeric only).
Required for Redis/Valkey; ignored for Memcached.
Rotate regularly; use AWS Secrets Manager or similar. | `string` | `null` | no | +| [auto\_minor\_version\_upgrade](#input\_auto\_minor\_version\_upgrade) | Enable automatic minor version upgrades during maintenance window. | `bool` | `true` | no | +| [automatic\_failover\_enabled](#input\_automatic\_failover\_enabled) | Enable automatic failover for Redis/Valkey replication groups. Default: true | `bool` | `true` | no | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [az\_mode](#input\_az\_mode) | Availability zone mode for standalone clusters (deployment\_mode = "cluster").
- single-az (default): all nodes in one AZ.
- cross-az: nodes spread across multiple AZs. Required for Memcached multi-node clusters.
Ignored for replication\_group and serverless deployment modes. | `string` | `null` | no | +| [cluster\_mode\_enabled](#input\_cluster\_mode\_enabled) | Enable cluster mode (sharding). When true, all nodes in each shard store the full dataset.
Allows horizontal scaling via multiple shards.
Default: true (recommended for production). | `bool` | `true` | no | +| [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | +| [create\_security\_group](#input\_create\_security\_group) | When false (default), supply existing security group IDs via security\_group\_ids — e.g.
from this repo's security-group module (feature/BCSS-23606-security-group-module).
When true, the upstream module creates a security group in var.vpc\_id using the
rules defined in security\_group\_rules. vpc\_id is required in this case. | `bool` | `false` | no | +| [data\_tiering\_enabled](#input\_data\_tiering\_enabled) | Enable data tiering (Redis 6.0+ with r6gd/r7gd instances only).
Allows overflow data to be stored on local NVMe SSD for cost savings.
Default: false | `bool` | `false` | no | +| [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | +| [deployment\_mode](#input\_deployment\_mode) | Controls which ElastiCache resource type is created.
- replication\_group (default): HA replication group with optional cluster mode and Multi-AZ.
Supports Valkey, Redis, and Memcached. Recommended for production and session storage.
- cluster: Standalone single/multi-node cluster without replication.
Useful for development, non-prod cost saving, or Memcached deployments.
- serverless: Auto-scaling serverless cache. Valkey and Redis only.
No node provisioning required; capacity scales on demand.
Note: Memcached only supports deployment\_mode = "cluster". | `string` | `"replication_group"` | no | +| [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | +| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [engine](#input\_engine) | ElastiCache engine. Supported: 'valkey' (recommended), 'redis', 'memcached'. | `string` | `"valkey"` | no | +| [engine\_version](#input\_engine\_version) | Engine version.
- Valkey: 7.2, 8.0
- Redis: 7.0, 7.1, 7.2 (or 6.x for older deployments; 6.0+ required for data tiering)
- Memcached: 1.6.x, 1.7.x | `string` | n/a | yes | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [final\_snapshot\_identifier\_prefix](#input\_final\_snapshot\_identifier\_prefix) | Prefix for the final snapshot name. When deletion is requested, a snapshot is created before deletion. Leave null to skip final snapshot. | `string` | `null` | no | +| [id\_length\_limit](#input\_id\_length\_limit) | Limit `id` to this many characters (minimum 6).
Set to `0` for unlimited length.
Set to `null` for keep the existing setting, which defaults to `0`.
Does not affect `id_full`. | `number` | `null` | no | +| [kms\_key\_arn](#input\_kms\_key\_arn) | Optional KMS key ARN for encryption at rest. When null, AWS-managed encryption is used.
To use a specific customer-managed KMS key, provide the ARN.
Encryption at rest is always enforced. | `string` | `null` | no | +| [label\_key\_case](#input\_label\_key\_case) | Controls the letter case of the `tags` keys (label names) for tags generated by this module.
Does not affect keys of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper`.
Default value: `title`. | `string` | `null` | no | +| [label\_order](#input\_label\_order) | The order in which the labels (ID elements) appear in the `id`.
Defaults to ["namespace", "environment", "stage", "name", "attributes"].
You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. | `list(string)` | `null` | no | +| [label\_value\_case](#input\_label\_value\_case) | Controls the letter case of ID elements (labels) as included in `id`,
set as tag values, and output by this module individually.
Does not affect values of tags passed in via the `tags` input.
Possible values: `lower`, `title`, `upper` and `none` (no transformation).
Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs.
Default value: `lower`. | `string` | `null` | no | +| [labels\_as\_tags](#input\_labels\_as\_tags) | Set of labels (ID elements) to include as tags in the `tags` output.
Default is to include all labels.
Tags with empty values will not be included in the `tags` output.
Set to `[]` to suppress all generated tags.
**Notes:**
The value of the `name` tag, if included, will be the `id`, not the `name`.
Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be
changed in later chained modules. Attempts to change it will be silently ignored. | `set(string)` |
[
"default"
]
| no | +| [log\_delivery\_configuration](#input\_log\_delivery\_configuration) | Log delivery configuration passed to the upstream module.
By default, both slow-log and engine-log are sent to CloudWatch Logs with
365-day retention and JSON format. The upstream module creates the log groups.
To pre-create log groups externally (e.g. via the cloudwatch module for KMS-encrypted
groups or custom retention), set create\_cloudwatch\_log\_group = false and supply
the destination group name per entry. Set to {} to disable all logging. | `any` |
{
"engine-log": {
"cloudwatch_log_group_retention_in_days": 365,
"destination_type": "cloudwatch-logs",
"log_format": "json"
},
"slow-log": {
"cloudwatch_log_group_retention_in_days": 365,
"destination_type": "cloudwatch-logs",
"log_format": "json"
}
}
| no | +| [maintenance\_window](#input\_maintenance\_window) | Time window for routine maintenance (UTC).
Format: ddd:hh24:mi-ddd:hh24:mi (e.g. 'sun:03:00-sun:05:00').
Default: Sunday 03:00-05:00 UTC. | `string` | `"sun:03:00-sun:05:00"` | no | +| [multi\_az\_enabled](#input\_multi\_az\_enabled) | Enable Multi-AZ failover. Recommended for production deployments. | `bool` | `true` | no | +| [name](#input\_name) | ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'.
This is the only ID element not also included as a `tag`.
The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. | `string` | `null` | no | +| [node\_type](#input\_node\_type) | Node instance type.
- Valkey/Redis: cache.t3.*, cache.t4g.*, cache.r6g.*, cache.r7g.*, cache.m6g.*, cache.m7g.*, etc.
- Memcached: cache.t3.*, cache.t4g.*, cache.m6g.*, etc.

Use cache.t3.small or cache.t4g.small for development; cache.r7g.* for production. | `string` | `null` | no | +| [notification\_topic\_arn](#input\_notification\_topic\_arn) | Optional SNS topic ARN for ElastiCache event notifications (failovers, updates, maintenance). Leave null to disable. | `string` | `null` | no | +| [num\_cache\_nodes](#input\_num\_cache\_nodes) | Number of cache nodes in the cluster. For non-cluster mode only; ignored when cluster\_mode\_enabled = true. | `number` | `2` | no | +| [num\_node\_groups](#input\_num\_node\_groups) | Number of shards in cluster mode. Only used when cluster\_mode\_enabled = true. Minimum 1, maximum 500. | `number` | `1` | no | +| [port](#input\_port) | Port on which ElastiCache listens. Default: 6379 for Redis/Valkey, 11211 for Memcached. Must match between clients and cache. | `number` | `6379` | no | +| [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no | +| [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | +| [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no | +| [replicas\_per\_node\_group](#input\_replicas\_per\_node\_group) | Number of replicas per shard (cluster mode) or per replication group (disabled cluster mode).
Each replica stores a copy of the dataset for high availability and failover.
Default: 2 (recommended for multi-AZ production deployments). | `number` | `2` | no | +| [security\_group\_ids](#input\_security\_group\_ids) | List of existing security group IDs to associate with the cache.
Required when create\_security\_group = false.
Typically sourced from this repo's security-group module. | `list(string)` | `[]` | no | +| [security\_group\_rules](#input\_security\_group\_rules) | Map of ingress and egress rules for the upstream-managed security group.
Only used when create\_security\_group = true.
See the upstream module documentation for the full shape of each rule entry. | `any` | `{}` | no | +| [serverless\_cache\_usage\_limits](#input\_serverless\_cache\_usage\_limits) | Optional capacity limits for serverless caches (deployment\_mode = "serverless").
Leave as {} for on-demand auto-scaling with no hard limits. Example:
serverless\_cache\_usage\_limits = {
data\_storage = { maximum = 100, unit = "GB" }
ecpu\_per\_second = { maximum = 5000 }
} | `any` | `{}` | no | +| [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no | +| [snapshot\_retention\_days](#input\_snapshot\_retention\_days) | Number of days to retain automated snapshots (Redis/Valkey only).
Memcached does not support snapshots.
Range: 1 to 35 days. Default: 5 days. | `number` | `5` | no | +| [snapshot\_window](#input\_snapshot\_window) | Time window in UTC when snapshots are taken (Redis/Valkey only).
Format: hh24:mi-hh24:mi (e.g. '03:00-05:00'). Default: '03:00-05:00' | `string` | `"03:00-05:00"` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [subnet\_ids](#input\_subnet\_ids) | List of subnet IDs for the ElastiCache subnet group.
Should be private subnets across multiple AZs for high availability.
Minimum: 2 subnets (different AZs); recommended for multi-AZ deployments. | `list(string)` | n/a | yes | +| [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | +| [tls\_version](#input\_tls\_version) | TLS version for encryption in transit. Valid: 'plaintext', 'tls'. Default: 'tls' (enforced). | `string` | `"tls"` | no | +| [vpc\_id](#input\_vpc\_id) | VPC ID. Required when create\_security\_group = true; otherwise optional. | `string` | `null` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [cloudwatch\_log\_group\_arn](#output\_cloudwatch\_log\_group\_arn) | ARN of the primary CloudWatch log group created by the upstream module. | +| [cloudwatch\_log\_group\_name](#output\_cloudwatch\_log\_group\_name) | Name of the primary CloudWatch log group created by the upstream module. | +| [cluster\_address](#output\_cluster\_address) | DNS name of the cache cluster (Memcached) or primary endpoint. | +| [cluster\_arn](#output\_cluster\_arn) | ARN of the standalone ElastiCache cluster. | +| [cluster\_configuration\_endpoint](#output\_cluster\_configuration\_endpoint) | Configuration endpoint for Memcached clusters (auto-discovery). | +| [configuration\_endpoint\_address](#output\_configuration\_endpoint\_address) | Configuration endpoint for cluster-mode replication groups (connects to all shards). | +| [id](#output\_id) | Active deployment mode: replication\_group, cluster, or serverless. | +| [maintenance\_window](#output\_maintenance\_window) | Maintenance window (UTC). | +| [member\_clusters](#output\_member\_clusters) | List of member node IDs in the replication group. | +| [port](#output\_port) | Port on which the ElastiCache resource listens. | +| [primary\_endpoint\_address](#output\_primary\_endpoint\_address) | Primary (writer) endpoint for the replication group. | +| [reader\_endpoint\_address](#output\_reader\_endpoint\_address) | Reader endpoint (load-balanced across replicas) for the replication group. | +| [replication\_group\_arn](#output\_replication\_group\_arn) | ARN of the ElastiCache replication group. | +| [replication\_group\_id](#output\_replication\_group\_id) | ID of the ElastiCache replication group. | +| [security\_group\_id](#output\_security\_group\_id) | First security group ID associated with the cache.
When create\_security\_group = false this is security\_group\_ids[0] (caller-managed).
When create\_security\_group = true, query the SG from the security-group module instead. | +| [serverless\_arn](#output\_serverless\_arn) | ARN of the serverless cache. | +| [serverless\_endpoint](#output\_serverless\_endpoint) | Connection endpoint (address and port) for the serverless cache. | +| [serverless\_reader\_endpoint](#output\_serverless\_reader\_endpoint) | Reader endpoint for the serverless cache. | +| [snapshot\_window](#output\_snapshot\_window) | Time window for automated snapshots (UTC). | + + + diff --git a/infrastructure/modules/elasticache/context.tf b/infrastructure/modules/elasticache/context.tf new file mode 100644 index 00000000..e921c83b --- /dev/null +++ b/infrastructure/modules/elasticache/context.tf @@ -0,0 +1,305 @@ +# tflint-ignore-file: terraform_standard_module_structure, terraform_unused_declarations +# +# ONLY EDIT THIS FILE IN github.com/NHSDigital/screening-terraform-modules-aws/infrastructure/modules/tags +# All other instances of this file should be a copy of that one +# +# +# Copy this file from https://github.com/NHSDigital/screening-terraform-modules-aws/blob/master/infrastructure/modules/tags/exports/context.tf +# and then place it in your Terraform module to automatically get +# tag module standard configuration inputs suitable for passing +# to other modules. +# +# curl -sL https://raw.githubusercontent.com/NHSDigital/screening-terraform-modules-aws/master/infrastructure/modules/tags/exports/context.tf -o context.tf +# +# Modules should access the whole context as `module.this.context` +# to get the input variables with nulls for defaults, +# for example `context = module.this.context`, +# and access individual variables as `module.this.`, +# with final values filled in. +# +# For example, when using defaults, `module.this.context.delimiter` +# will be null, and `module.this.delimiter` will be `-` (hyphen). +# + +module "this" { + # tflint-ignore: terraform_module_pinned_source + source = "../tags" + + enabled = var.enabled + service = var.service + project = var.project + region = var.region + environment = var.environment + stack = var.stack + workspace = var.workspace + name = var.name + delimiter = var.delimiter + attributes = var.attributes + tags = var.tags + additional_tag_map = var.additional_tag_map + label_order = var.label_order + regex_replace_chars = var.regex_replace_chars + id_length_limit = var.id_length_limit + label_key_case = var.label_key_case + label_value_case = var.label_value_case + terraform_source = coalesce(var.terraform_source, path.module) + descriptor_formats = var.descriptor_formats + labels_as_tags = var.labels_as_tags + + context = var.context +} + +# Copy contents of screening-terraform-modules-aws/tags/variables.tf here +# tflint-ignore: terraform_unused_declarations +variable "aws_region" { + type = string + description = "The AWS region" + default = "eu-west-2" + validation { + condition = contains(["eu-west-1", "eu-west-2", "us-east-1"], var.aws_region) + error_message = "AWS Region must be one of eu-west-1, eu-west-2, us-east-1" + } +} + +variable "context" { + type = any + default = { + enabled = true + service = null + project = null + region = null + environment = null + stack = null + workspace = null + name = null + delimiter = null + attributes = [] + tags = {} + additional_tag_map = {} + regex_replace_chars = null + label_order = [] + id_length_limit = null + label_key_case = null + label_value_case = null + terraform_source = null + descriptor_formats = {} + labels_as_tags = ["unset"] + } + description = <<-EOT + Single object for setting entire context at once. + See description of individual variables for details. + Leave string and numeric variables as `null` to use default value. + Individual variable settings (non-null) override settings in context object, + except for attributes, tags, and additional_tag_map, which are merged. + EOT + + validation { + condition = lookup(var.context, "label_key_case", null) == null ? true : contains(["lower", "title", "upper"], var.context["label_key_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`." + } + + validation { + condition = lookup(var.context, "label_value_case", null) == null ? true : contains(["lower", "title", "upper", "none"], var.context["label_value_case"]) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "terraform_source" { + type = string + default = null + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." +} + +variable "enabled" { + type = bool + default = null + description = "Set to false to prevent the module from creating any resources" +} + +variable "service" { + type = string + default = null + description = "ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique" +} + +variable "region" { + type = string + default = null + description = "ID element _(Rarely used, not included by default)_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region" +} + +variable "project" { + type = string + default = null + description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`" +} + +variable "stack" { + type = string + default = null + description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`" +} + +variable "workspace" { + type = string + default = null + description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces" +} + +variable "environment" { + type = string + default = null + description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'" +} + +variable "name" { + type = string + default = null + description = <<-EOT + ID element. Usually the component or solution name, e.g. 'app' or 'jenkins'. + This is the only ID element not also included as a `tag`. + The "name" tag is set to the full `id` string. There is no tag with the value of the `name` input. + EOT +} + +variable "delimiter" { + type = string + default = null + description = <<-EOT + Delimiter to be used between ID elements. + Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. + EOT +} + +variable "attributes" { + type = list(string) + default = [] + description = <<-EOT + ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`, + in the order they appear in the list. New attributes are appended to the + end of the list. The elements of the list are joined by the `delimiter` + and treated as a single ID element. + EOT +} + +variable "labels_as_tags" { + type = set(string) + default = ["default"] + description = <<-EOT + Set of labels (ID elements) to include as tags in the `tags` output. + Default is to include all labels. + Tags with empty values will not be included in the `tags` output. + Set to `[]` to suppress all generated tags. + **Notes:** + The value of the `name` tag, if included, will be the `id`, not the `name`. + Unlike other `null-label` inputs, the initial setting of `labels_as_tags` cannot be + changed in later chained modules. Attempts to change it will be silently ignored. + EOT +} + +variable "tags" { + type = map(string) + default = {} + description = <<-EOT + Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`). + Neither the tag keys nor the tag values will be modified by this module. + EOT +} + +variable "additional_tag_map" { + type = map(string) + default = {} + description = <<-EOT + Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`. + This is for some rare cases where resources want additional configuration of tags + and therefore take a list of maps with tag key, value, and additional configuration. + EOT +} + +variable "label_order" { + type = list(string) + default = null + description = <<-EOT + The order in which the labels (ID elements) appear in the `id`. + Defaults to ["namespace", "environment", "stage", "name", "attributes"]. + You can omit any of the 6 labels ("tenant" is the 6th), but at least one must be present. + EOT +} + +variable "regex_replace_chars" { + type = string + default = null + description = <<-EOT + Terraform regular expression (regex) string. + Characters matching the regex will be removed from the ID elements. + If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. + EOT +} + +variable "id_length_limit" { + type = number + default = null + description = <<-EOT + Limit `id` to this many characters (minimum 6). + Set to `0` for unlimited length. + Set to `null` for keep the existing setting, which defaults to `0`. + Does not affect `id_full`. + EOT + validation { + condition = var.id_length_limit == null ? true : var.id_length_limit >= 6 || var.id_length_limit == 0 + error_message = "The id_length_limit must be >= 6 if supplied (not null), or 0 for unlimited length." + } +} + +variable "label_key_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of the `tags` keys (label names) for tags generated by this module. + Does not affect keys of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper`. + Default value: `title`. + EOT + + validation { + condition = var.label_key_case == null ? true : contains(["lower", "title", "upper"], var.label_key_case) + error_message = "Allowed values: `lower`, `title`, `upper`." + } +} + +variable "label_value_case" { + type = string + default = null + description = <<-EOT + Controls the letter case of ID elements (labels) as included in `id`, + set as tag values, and output by this module individually. + Does not affect values of tags passed in via the `tags` input. + Possible values: `lower`, `title`, `upper` and `none` (no transformation). + Set this to `title` and set `delimiter` to `""` to yield Pascal Case IDs. + Default value: `lower`. + EOT + + validation { + condition = var.label_value_case == null ? true : contains(["lower", "title", "upper", "none"], var.label_value_case) + error_message = "Allowed values: `lower`, `title`, `upper`, `none`." + } +} + +variable "descriptor_formats" { + type = any + default = {} + description = <<-EOT + Describe additional descriptors to be output in the `descriptors` output map. + Map of maps. Keys are names of descriptors. Values are maps of the form + `{ + format = string + labels = list(string) + }` + (Type is `any` so the map values can later be enhanced to provide additional options.) + `format` is a Terraform format string to be passed to the `format()` function. + `labels` is a list of labels, in order, to pass to `format()` function. + Label values will be normalized before being passed to `format()` so they will be + identical to how they appear in `id`. + Default is `{}` (`descriptors` output will be empty). + EOT +} diff --git a/infrastructure/modules/elasticache/locals.tf b/infrastructure/modules/elasticache/locals.tf index 466dcadd..0e92f973 100644 --- a/infrastructure/modules/elasticache/locals.tf +++ b/infrastructure/modules/elasticache/locals.tf @@ -1,9 +1,25 @@ locals { - replication_group_id = "${var.name_prefix}-${var.name}" - #for engine-log prefix https://docs.aws.amazon.com/step-functions/latest/dg/bp-cwl.html - cw_redis_engine_log = "/aws/vendedlogs/${var.name_prefix}-redis-engine-logs" - cw_redis_slow_log = "/aws/vendedlogs/${var.name_prefix}-redis-slow-logs" - subnet_group = "${var.name_prefix}-${var.name}" - sg_name = "${var.name_prefix}-${var.name}" - parameter_group_name = var.name_prefix + # ================================================================ + # Deployment mode + # ================================================================ + create_replication_group = var.deployment_mode == "replication_group" + create_cluster = var.deployment_mode == "cluster" + create_serverless_cache = var.deployment_mode == "serverless" + + # ================================================================ + # Naming + # ================================================================ + replication_group_id = module.this.id + cluster_id = module.this.id + replication_group_description = "ElastiCache ${var.engine} for ${module.this.id}" + final_snapshot_id = "${module.this.id}-final-snapshot" + + # ================================================================ + # Engine helpers + # ================================================================ + major_engine_version = split(".", var.engine_version)[0] + + # Extract HH:MM from snapshot_window (format hh:mi-hh:mi) for the serverless + # daily_snapshot_time argument which expects HH:MM format. + serverless_snapshot_time = var.snapshot_window != null ? split("-", var.snapshot_window)[0] : null } diff --git a/infrastructure/modules/elasticache/main.tf b/infrastructure/modules/elasticache/main.tf index bd135725..cfc85834 100644 --- a/infrastructure/modules/elasticache/main.tf +++ b/infrastructure/modules/elasticache/main.tf @@ -1,107 +1,204 @@ -###################### -# Elasticache -###################### - -resource "aws_elasticache_replication_group" "elasticache_replication_group" { - replication_group_id = local.replication_group_id - description = var.replication_group_description - node_type = var.node_type +################################################################ +# ElastiCache +# +# Thin NHS wrapper around the community +# terraform-aws-modules/elasticache/aws module that enforces +# the screening platform's baseline controls: +# +# * Encryption in transit (TLS) — enforced +# * Encryption at rest — enforced for all engines +# * Multi-AZ with automatic failover — configurable +# * Authentication — AUTH token required for Redis/Valkey +# * VPC isolation — via security groups and subnet groups +# * Logging — engine logs and slow logs to CloudWatch +# * Backup and retention — configurable with sensible defaults +# * No public access — clusters are private by default +# +# Naming and tagging are derived from context.tf via module.this. +# +# Supported engines: Valkey (recommended), Redis (5.0+), Memcached +# Deployment modes (var.deployment_mode): +# replication_group — HA replication group; default; production recommended +# cluster — standalone single/multi-node cluster; lower cost dev/test +# serverless — auto-scaling serverless cache (Valkey/Redis only) +################################################################ + +module "elasticache" { + source = "terraform-aws-modules/elasticache/aws" + version = "1.11.0" + + # Not used for serverless; that mode is handled by the separate module block below. + create = module.this.enabled && !local.create_serverless_cache + + # ---------------------------------------------------------------- + # Control resource creation + # ---------------------------------------------------------------- (driven by var.deployment_mode) + create_cluster = local.create_cluster + create_replication_group = local.create_replication_group + + # ---------------------------------------------------------------- + # Engine and cluster configuration + # ---------------------------------------------------------------- + engine = var.engine + engine_version = var.engine_version + node_type = var.node_type + + # Standalone cluster: simple primary (+ additional nodes for Memcached cross-az) + num_cache_nodes = var.num_cache_nodes + az_mode = var.az_mode + + # Replication group without cluster mode: total count (primary + replicas) + num_cache_clusters = local.create_replication_group && !var.cluster_mode_enabled ? var.num_cache_nodes : null + + # Cluster mode: shared dataset with shards (all nodes store full dataset) + cluster_mode_enabled = var.cluster_mode_enabled + + # Replicas per node group (for cluster mode) + replicas_per_node_group = var.cluster_mode_enabled ? var.replicas_per_node_group : null + num_node_groups = var.cluster_mode_enabled ? var.num_node_groups : null + + # ---------------------------------------------------------------- + # Resource identifiers + # ---------------------------------------------------------------- + replication_group_id = local.replication_group_id + cluster_id = local.cluster_id + description = local.replication_group_description + + # ---------------------------------------------------------------- + # Encryption: in transit (TLS) and at rest — ENFORCED + # ---------------------------------------------------------------- transit_encryption_enabled = true + transit_encryption_mode = var.tls_version at_rest_encryption_enabled = true - auth_token = var.redis_auth_token - port = var.elasticache_port - apply_immediately = var.apply_immediately - parameter_group_name = aws_elasticache_parameter_group.bss_param_group_redis7.name - automatic_failover_enabled = var.auto_failover_enabled - auto_minor_version_upgrade = true - maintenance_window = "Mon:00:00-Mon:03:00" - snapshot_window = "04:00-08:00" - notification_topic_arn = var.notification_topic_arn - subnet_group_name = aws_elasticache_subnet_group.cache_subnet_group.name - security_group_ids = [aws_security_group.cache_sg.id] - engine_version = var.engine_version - cluster_mode = "enabled" - replicas_per_node_group = var.replicas_per_node_group - num_node_groups = var.number_of_shards - - log_delivery_configuration { - destination = aws_cloudwatch_log_group.redis_engine_log.name - destination_type = "cloudwatch-logs" - log_format = "text" - log_type = "engine-log" - } - - log_delivery_configuration { - destination = aws_cloudwatch_log_group.redis_slow_log.name - destination_type = "cloudwatch-logs" - log_format = "text" - log_type = "slow-log" - } - depends_on = [aws_cloudwatch_log_group.redis_engine_log, aws_cloudwatch_log_group.redis_slow_log] -} + kms_key_arn = var.kms_key_arn -resource "aws_iam_service_linked_role" "elasticache" { - count = var.create_elasticache_service_role ? 1 : 0 - aws_service_name = "elasticache.amazonaws.com" -} + # Authentication: AUTH token for Redis/Valkey + auth_token = var.auth_token + auth_token_update_strategy = "ROTATE" -# to allow referencing the existing service linked role if not created -data "aws_iam_role" "elasticache" { - name = "AWSServiceRoleForElastiCache" - depends_on = [aws_iam_service_linked_role.elasticache] -} + # ---------------------------------------------------------------- + # Automatic failover and availability + # ---------------------------------------------------------------- + automatic_failover_enabled = var.automatic_failover_enabled + multi_az_enabled = var.multi_az_enabled + auto_minor_version_upgrade = var.auto_minor_version_upgrade -resource "aws_elasticache_parameter_group" "bss_param_group_redis7" { - name = "${local.parameter_group_name}-redis7" - family = "redis7" - - parameter { - name = "cluster-enabled" - value = "yes" - } - lifecycle { - create_before_destroy = true - } - depends_on = [data.aws_iam_role.elasticache] -} + # ---------------------------------------------------------------- + # Maintenance and backup + # ---------------------------------------------------------------- + maintenance_window = var.maintenance_window + notification_topic_arn = var.notification_topic_arn + apply_immediately = var.apply_immediately -###################### -# Networking -###################### + # Snapshots (for Redis/Valkey only; ignored for Memcached) + snapshot_window = var.snapshot_window + snapshot_retention_limit = var.snapshot_retention_days + final_snapshot_identifier = var.final_snapshot_identifier_prefix != null ? "${var.final_snapshot_identifier_prefix}-${local.final_snapshot_id}" : null -resource "aws_elasticache_subnet_group" "cache_subnet_group" { - name = local.subnet_group - description = "Subnet group for Elasticache" - subnet_ids = var.subnet_ids - depends_on = [data.aws_iam_role.elasticache] -} + # Data tiering (for Redis 6.0+; requires r6gd instances) + data_tiering_enabled = var.data_tiering_enabled -resource "aws_security_group" "cache_sg" { - name = local.sg_name - description = "Allow connection by appointed cache clients" - vpc_id = var.vpc_id -} + # ---------------------------------------------------------------- + # Networking + # ---------------------------------------------------------------- + # Subnet group: created by upstream module using subnet_ids + create_subnet_group = true + subnet_ids = var.subnet_ids + vpc_id = var.vpc_id -resource "aws_vpc_security_group_ingress_rule" "ecs_inbound" { - description = "Allows inbound connection from ECS cluster" - security_group_id = aws_security_group.cache_sg.id - referenced_security_group_id = var.ecs_sg_id - from_port = 6379 - to_port = 6379 - ip_protocol = "tcp" - tags = { - "Name" : "${var.name_prefix}-ecs" - } -} + # Security group: + # Option A — pass existing SG IDs from the security-group module + # (feature/BCSS-23606-security-group-module): + # create_security_group = false + # security_group_ids = [module.cache_sg.id] + # Option B — let the upstream module create one inline: + # create_security_group = true + # security_group_rules = { ... } + create_security_group = var.create_security_group + security_group_ids = var.security_group_ids + security_group_rules = var.security_group_rules + security_group_tags = module.this.tags + + # Port configuration + port = var.port + + # ---------------------------------------------------------------- + # Logging: engine logs and slow logs to CloudWatch + # ---------------------------------------------------------------- + # Delegated to upstream module built-in log group creation. + # + # TODO: Pre-create log groups via the cloudwatch module for stronger + # control over KMS key, retention class, and skip_destroy behaviour. Example + # using terraform-aws-modules/cloudwatch/aws//modules/log-group: + # + # module "cache_logs_slow" { + # source = "terraform-aws-modules/cloudwatch/aws//modules/log-group" + # version = "~> 5.0" + # name = "/aws/elasticache/${module.this.id}/slow-log" + # retention_in_days = 365 + # kms_key_id = var.kms_key_arn + # skip_destroy = true + # tags = module.this.tags + # } + # module "cache_logs_engine" { + # source = "terraform-aws-modules/cloudwatch/aws//modules/log-group" + # version = "~> 5.0" + # name = "/aws/elasticache/${module.this.id}/engine-log" + # retention_in_days = 365 + # kms_key_id = var.kms_key_arn + # skip_destroy = true + # tags = module.this.tags + # } + # Then reference them with create_cloudwatch_log_group = false: + # log_delivery_configuration = { + # slow-log = { + # destination_type = "cloudwatch-logs" + # log_format = "json" + # create_cloudwatch_log_group = false + # destination = module.cache_logs_slow.cloudwatch_log_group_name + # } + # engine-log = { + # destination_type = "cloudwatch-logs" + # log_format = "json" + # create_cloudwatch_log_group = false + # destination = module.cache_logs_engine.cloudwatch_log_group_name + # } + # } + log_delivery_configuration = var.log_delivery_configuration + + tags = module.this.tags -resource "aws_cloudwatch_log_group" "redis_engine_log" { - name = local.cw_redis_engine_log - #kms_key_id = data.aws_kms_key.kms_key.arn - retention_in_days = 365 } -resource "aws_cloudwatch_log_group" "redis_slow_log" { - name = local.cw_redis_slow_log - #kms_key_id = data.aws_kms_key.kms_key.arn - retention_in_days = 365 +# ================================================================ +# Serverless Cache (deployment_mode = "serverless") +# ================================================================ +module "elasticache_serverless" { + source = "terraform-aws-modules/elasticache/aws//modules/serverless-cache" + version = "1.11.0" + + create = module.this.enabled && local.create_serverless_cache + + cache_name = local.replication_group_id + engine = var.engine + major_engine_version = local.major_engine_version + description = local.replication_group_description + + # Encryption at rest — ENFORCED; pass in from the kms module or null for AWS-managed + kms_key_id = var.kms_key_arn + + # Networking: pass security_group_ids from the security-group module. + # create_security_group above does not affect the serverless module. + security_group_ids = var.security_group_ids + subnet_ids = var.subnet_ids + + # Backup (Redis only; ignored for Valkey) + snapshot_retention_limit = var.snapshot_retention_days + daily_snapshot_time = local.serverless_snapshot_time + + # Optional capacity limits (data_storage and ecpu_per_second). + # Leave as {} for on-demand auto-scaling with no hard limits. + cache_usage_limits = var.serverless_cache_usage_limits + + tags = module.this.tags } diff --git a/infrastructure/modules/elasticache/outputs.tf b/infrastructure/modules/elasticache/outputs.tf index 7bee440a..b30221ab 100644 --- a/infrastructure/modules/elasticache/outputs.tf +++ b/infrastructure/modules/elasticache/outputs.tf @@ -1,14 +1,122 @@ -output "redis_configuration_endpoint_address" { - description = "Configuration endpoint address for the ElastiCache replication group." - value = aws_elasticache_replication_group.elasticache_replication_group.configuration_endpoint_address +output "id" { + description = "Active deployment mode: replication_group, cluster, or serverless." + value = var.deployment_mode } -output "redis_configuration_endpoint_port" { - description = "Configuration endpoint port for the ElastiCache replication group." - value = aws_elasticache_replication_group.elasticache_replication_group.port +# ================================================================ +# Replication group (deployment_mode = "replication_group") +# ================================================================ + +output "replication_group_id" { + description = "ID of the ElastiCache replication group." + value = module.this.enabled && local.create_replication_group ? module.elasticache.replication_group_id : null +} + +output "replication_group_arn" { + description = "ARN of the ElastiCache replication group." + value = module.this.enabled && local.create_replication_group ? module.elasticache.replication_group_arn : null +} + +output "primary_endpoint_address" { + description = "Primary (writer) endpoint for the replication group." + value = module.this.enabled && local.create_replication_group ? module.elasticache.replication_group_primary_endpoint_address : null +} + +output "reader_endpoint_address" { + description = "Reader endpoint (load-balanced across replicas) for the replication group." + value = module.this.enabled && local.create_replication_group ? module.elasticache.replication_group_reader_endpoint_address : null +} + +output "configuration_endpoint_address" { + description = "Configuration endpoint for cluster-mode replication groups (connects to all shards)." + value = module.this.enabled && local.create_replication_group ? module.elasticache.replication_group_configuration_endpoint_address : null +} + +output "member_clusters" { + description = "List of member node IDs in the replication group." + value = module.this.enabled && local.create_replication_group ? module.elasticache.replication_group_member_clusters : [] +} + +output "port" { + description = "Port on which the ElastiCache resource listens." + value = module.this.enabled && local.create_replication_group ? module.elasticache.replication_group_port : var.port +} + +# ================================================================ +# Cluster (deployment_mode = "cluster") +# ================================================================ + +output "cluster_arn" { + description = "ARN of the standalone ElastiCache cluster." + value = module.this.enabled && local.create_cluster ? module.elasticache.cluster_arn : null +} + +output "cluster_address" { + description = "DNS name of the cache cluster (Memcached) or primary endpoint." + value = module.this.enabled && local.create_cluster ? module.elasticache.cluster_address : null +} + +output "cluster_configuration_endpoint" { + description = "Configuration endpoint for Memcached clusters (auto-discovery)." + value = module.this.enabled && local.create_cluster ? module.elasticache.cluster_configuration_endpoint : null +} + +# ================================================================ +# Serverless (deployment_mode = "serverless") +# ================================================================ + +output "serverless_arn" { + description = "ARN of the serverless cache." + value = module.this.enabled && local.create_serverless_cache ? module.elasticache_serverless.serverless_cache_arn : null +} + +output "serverless_endpoint" { + description = "Connection endpoint (address and port) for the serverless cache." + value = module.this.enabled && local.create_serverless_cache ? module.elasticache_serverless.serverless_cache_endpoint : null +} + +output "serverless_reader_endpoint" { + description = "Reader endpoint for the serverless cache." + value = module.this.enabled && local.create_serverless_cache ? module.elasticache_serverless.serverless_cache_reader_endpoint : null +} + +# ================================================================ +# Networking +# ================================================================ + +output "security_group_id" { + description = <<-EOT + First security group ID associated with the cache. + When create_security_group = false this is security_group_ids[0] (caller-managed). + When create_security_group = true, query the SG from the security-group module instead. + EOT + value = module.this.enabled && !var.create_security_group && length(var.security_group_ids) > 0 ? var.security_group_ids[0] : null +} + +# ================================================================ +# Logging +# ================================================================ + +output "cloudwatch_log_group_name" { + description = "Name of the primary CloudWatch log group created by the upstream module." + value = module.this.enabled && !local.create_serverless_cache ? module.elasticache.cloudwatch_log_group_name : null +} + +output "cloudwatch_log_group_arn" { + description = "ARN of the primary CloudWatch log group created by the upstream module." + value = module.this.enabled && !local.create_serverless_cache ? module.elasticache.cloudwatch_log_group_arn : null +} + +# ================================================================ +# Maintenance & backup +# ================================================================ + +output "snapshot_window" { + description = "Time window for automated snapshots (UTC)." + value = var.snapshot_window } -output "redis_security_group_id" { - description = "Security group ID attached to the ElastiCache replication group." - value = aws_security_group.cache_sg.id +output "maintenance_window" { + description = "Maintenance window (UTC)." + value = var.maintenance_window } diff --git a/infrastructure/modules/elasticache/readme.md b/infrastructure/modules/elasticache/readme.md deleted file mode 100644 index 2e696340..00000000 --- a/infrastructure/modules/elasticache/readme.md +++ /dev/null @@ -1,70 +0,0 @@ -# Elasticache - - - - -## Requirements - -| Name | Version | -| ---- | ------- | -| [terraform](#requirement\_terraform) | >= 1.13 | -| [aws](#requirement\_aws) | >= 6.42 | - -## Providers - -| Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.50.0 | - -## Modules - -No modules. - -## Resources - -| Name | Type | -| ---- | ---- | -| [aws_cloudwatch_log_group.redis_engine_log](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | -| [aws_cloudwatch_log_group.redis_slow_log](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cloudwatch_log_group) | resource | -| [aws_elasticache_parameter_group.bss_param_group_redis7](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/elasticache_parameter_group) | resource | -| [aws_elasticache_replication_group.elasticache_replication_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/elasticache_replication_group) | resource | -| [aws_elasticache_subnet_group.cache_subnet_group](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/elasticache_subnet_group) | resource | -| [aws_iam_service_linked_role.elasticache](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/iam_service_linked_role) | resource | -| [aws_security_group.cache_sg](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/security_group) | resource | -| [aws_vpc_security_group_ingress_rule.ecs_inbound](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/vpc_security_group_ingress_rule) | resource | -| [aws_iam_role.elasticache](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/iam_role) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | -| [apply\_immediately](#input\_apply\_immediately) | whether to apply changes immediately - false will apply in maintenance window | `bool` | `false` | no | -| [auto\_failover\_enabled](#input\_auto\_failover\_enabled) | Whether automatic failover is enabled for the replication group. | `bool` | n/a | yes | -| [aws\_account\_id](#input\_aws\_account\_id) | The AWS account ID | `string` | n/a | yes | -| [create\_elasticache\_service\_role](#input\_create\_elasticache\_service\_role) | The service role can only be created once per account, only enable it in one stack | `bool` | `true` | no | -| [ecs\_sg\_id](#input\_ecs\_sg\_id) | The id of the ECS security group to enable access for | `string` | n/a | yes | -| [elasticache\_port](#input\_elasticache\_port) | Port on which Elasticache runs | `number` | `6379` | no | -| [engine\_version](#input\_engine\_version) | The Elasticache engine version | `string` | n/a | yes | -| [environment](#input\_environment) | The name of the Environment this is deployed into, for example CICD, NFT, UAT or PROD | `string` | n/a | yes | -| [multi\_az](#input\_multi\_az) | Legacy toggle retained for backwards compatibility. | `bool` | n/a | yes | -| [name](#input\_name) | The name of the resource | `string` | `"elasticache"` | no | -| [name\_prefix](#input\_name\_prefix) | the prefix for the name which containts the environment and business unit | `string` | n/a | yes | -| [node\_type](#input\_node\_type) | Node instance type for ElastiCache replication group nodes. | `string` | n/a | yes | -| [notification\_topic\_arn](#input\_notification\_topic\_arn) | Name of the SNS topic used for Elasticache alerts | `string` | n/a | yes | -| [number\_of\_shards](#input\_number\_of\_shards) | Number of shard groups in the replication group. | `number` | `1` | no | -| [redis\_auth\_token](#input\_redis\_auth\_token) | Auth token for Redis cache | `string` | n/a | yes | -| [replicas\_per\_node\_group](#input\_replicas\_per\_node\_group) | Number of replicas per shard group. | `number` | `2` | no | -| [replication\_group\_description](#input\_replication\_group\_description) | Description for replication group | `string` | `"Redis cache for BS-Select application"` | no | -| [subnet\_ids](#input\_subnet\_ids) | The subnets that will be used for elasticache, usually private | `list(string)` | n/a | yes | -| [vpc\_id](#input\_vpc\_id) | The ID for the VPC | `string` | n/a | yes | - -## Outputs - -| Name | Description | -| ---- | ----------- | -| [redis\_configuration\_endpoint\_address](#output\_redis\_configuration\_endpoint\_address) | Configuration endpoint address for the ElastiCache replication group. | -| [redis\_configuration\_endpoint\_port](#output\_redis\_configuration\_endpoint\_port) | Configuration endpoint port for the ElastiCache replication group. | -| [redis\_security\_group\_id](#output\_redis\_security\_group\_id) | Security group ID attached to the ElastiCache replication group. | - - - diff --git a/infrastructure/modules/elasticache/variables.tf b/infrastructure/modules/elasticache/variables.tf index 79d1a7ee..2abe3c28 100644 --- a/infrastructure/modules/elasticache/variables.tf +++ b/infrastructure/modules/elasticache/variables.tf @@ -1,106 +1,375 @@ -variable "node_type" { - description = "Node instance type for ElastiCache replication group nodes." +################################################################ +# ElastiCache-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# context.tf via `module.this`. +################################################################ + +variable "engine" { + description = "ElastiCache engine. Supported: 'valkey' (recommended), 'redis', 'memcached'." + type = string + default = "valkey" + + validation { + condition = contains(["valkey", "redis", "memcached"], lower(var.engine)) + error_message = "Engine must be one of: valkey, redis, memcached" + } +} +variable "deployment_mode" { + description = <<-EOT + Controls which ElastiCache resource type is created. + - replication_group (default): HA replication group with optional cluster mode and Multi-AZ. + Supports Valkey, Redis, and Memcached. Recommended for production and session storage. + - cluster: Standalone single/multi-node cluster without replication. + Useful for development, non-prod cost saving, or Memcached deployments. + - serverless: Auto-scaling serverless cache. Valkey and Redis only. + No node provisioning required; capacity scales on demand. + Note: Memcached only supports deployment_mode = "cluster". + EOT type = string + default = "replication_group" + + validation { + condition = contains(["replication_group", "cluster", "serverless"], var.deployment_mode) + error_message = "deployment_mode must be one of: replication_group, cluster, serverless" + } } + variable "engine_version" { - description = "The Elasticache engine version" + description = <<-EOT + Engine version. + - Valkey: 7.2, 8.0 + - Redis: 7.0, 7.1, 7.2 (or 6.x for older deployments; 6.0+ required for data tiering) + - Memcached: 1.6.x, 1.7.x + EOT + type = string + + validation { + condition = can(regex("^\\d+\\.\\d+", var.engine_version)) + error_message = "Engine version must be in format X.Y or X.Y.Z" + } +} + +variable "node_type" { + description = <<-EOT + Node instance type. + - Valkey/Redis: cache.t3.*, cache.t4g.*, cache.r6g.*, cache.r7g.*, cache.m6g.*, cache.m7g.*, etc. + - Memcached: cache.t3.*, cache.t4g.*, cache.m6g.*, etc. + + Use cache.t3.small or cache.t4g.small for development; cache.r7g.* for production. + EOT + type = string + default = null # Not required for deployment_mode = "serverless" + + validation { + condition = var.node_type == null || can(regex("^cache\\.[a-z0-9]+\\.[a-z0-9]+", var.node_type)) + error_message = "node_type must be a valid ElastiCache instance type (e.g. cache.r7g.large) or null for serverless." + } +} + +variable "num_cache_nodes" { + description = "Number of cache nodes in the cluster. For non-cluster mode only; ignored when cluster_mode_enabled = true." + type = number + default = 2 + + validation { + condition = var.num_cache_nodes >= 1 && var.num_cache_nodes <= 500 + error_message = "num_cache_nodes must be between 1 and 500" + } +} + +variable "az_mode" { + description = <<-EOT + Availability zone mode for standalone clusters (deployment_mode = "cluster"). + - single-az (default): all nodes in one AZ. + - cross-az: nodes spread across multiple AZs. Required for Memcached multi-node clusters. + Ignored for replication_group and serverless deployment modes. + EOT type = string + default = null + + validation { + condition = var.az_mode == null || contains(["single-az", "cross-az"], var.az_mode) + error_message = "az_mode must be null, single-az, or cross-az" + } } -variable "auto_failover_enabled" { - description = "Whether automatic failover is enabled for the replication group." +variable "cluster_mode_enabled" { + description = <<-EOT + Enable cluster mode (sharding). When true, all nodes in each shard store the full dataset. + Allows horizontal scaling via multiple shards. + Default: true (recommended for production). + EOT type = bool + default = true } -variable "number_of_shards" { - description = "Number of shard groups in the replication group." +variable "num_node_groups" { + description = "Number of shards in cluster mode. Only used when cluster_mode_enabled = true. Minimum 1, maximum 500." type = number default = 1 + + validation { + condition = var.num_node_groups >= 1 && var.num_node_groups <= 500 + error_message = "num_node_groups must be between 1 and 500" + } } variable "replicas_per_node_group" { - description = "Number of replicas per shard group." + description = <<-EOT + Number of replicas per shard (cluster mode) or per replication group (disabled cluster mode). + Each replica stores a copy of the dataset for high availability and failover. + Default: 2 (recommended for multi-AZ production deployments). + EOT type = number default = 2 + + validation { + condition = var.replicas_per_node_group >= 0 && var.replicas_per_node_group <= 5 + error_message = "replicas_per_node_group must be between 0 and 5" + } } -variable "replication_group_description" { - description = "Description for replication group" - type = string - default = "Redis cache for BS-Select application" + + +variable "multi_az_enabled" { + description = "Enable Multi-AZ failover. Recommended for production deployments." + type = bool + default = true +} + +variable "automatic_failover_enabled" { + description = "Enable automatic failover for Redis/Valkey replication groups. Default: true" + type = bool + default = true } -# tflint-ignore: terraform_unused_declarations -variable "multi_az" { - description = "Legacy toggle retained for backwards compatibility." +variable "auto_minor_version_upgrade" { + description = "Enable automatic minor version upgrades during maintenance window." type = bool + default = true } -variable "elasticache_port" { - description = "Port on which Elasticache runs" +variable "port" { + description = "Port on which ElastiCache listens. Default: 6379 for Redis/Valkey, 11211 for Memcached. Must match between clients and cache." type = number default = 6379 -} -variable "apply_immediately" { - description = "whether to apply changes immediately - false will apply in maintenance window" - type = bool - default = false + validation { + condition = var.port > 0 && var.port <= 65535 + error_message = "Port must be between 1 and 65535" + } } -variable "redis_auth_token" { - description = "Auth token for Redis cache" +# ================================================================ +# Encryption (enforced) +# ================================================================ + +variable "tls_version" { + description = "TLS version for encryption in transit. Valid: 'plaintext', 'tls'. Default: 'tls' (enforced)." type = string - sensitive = true + default = "tls" + + validation { + condition = contains(["plaintext", "tls"], var.tls_version) + error_message = "tls_version must be 'plaintext' or 'tls'; only 'tls' recommended" + } } -variable "notification_topic_arn" { - description = "Name of the SNS topic used for Elasticache alerts" +variable "kms_key_arn" { + description = <<-EOT + Optional KMS key ARN for encryption at rest. When null, AWS-managed encryption is used. + To use a specific customer-managed KMS key, provide the ARN. + Encryption at rest is always enforced. + EOT type = string + default = null } -variable "name_prefix" { - description = "the prefix for the name which containts the environment and business unit" +variable "auth_token" { + description = <<-EOT + Authentication token for Redis/Valkey clusters (16-128 characters, alphanumeric only). + Required for Redis/Valkey; ignored for Memcached. + Rotate regularly; use AWS Secrets Manager or similar. + EOT type = string + sensitive = true + default = null } -variable "name" { - description = "The name of the resource" - type = string - default = "elasticache" +# ================================================================ +# Backup & Snapshot Configuration +# ================================================================ + +variable "snapshot_retention_days" { + description = <<-EOT + Number of days to retain automated snapshots (Redis/Valkey only). + Memcached does not support snapshots. + Range: 1 to 35 days. Default: 5 days. + EOT + type = number + default = 5 + + validation { + condition = var.snapshot_retention_days >= 1 && var.snapshot_retention_days <= 35 + error_message = "snapshot_retention_days must be between 1 and 35" + } } -# tflint-ignore: terraform_unused_declarations -variable "environment" { - description = "The name of the Environment this is deployed into, for example CICD, NFT, UAT or PROD" +variable "snapshot_window" { + description = <<-EOT + Time window in UTC when snapshots are taken (Redis/Valkey only). + Format: hh24:mi-hh24:mi (e.g. '03:00-05:00'). Default: '03:00-05:00' + EOT type = string + default = "03:00-05:00" + + validation { + condition = can(regex("^[0-2][0-9]:[0-5][0-9]-[0-2][0-9]:[0-5][0-9]$", var.snapshot_window)) + error_message = "snapshot_window must be in format hh24:mi-hh24:mi" + } } -# tflint-ignore: terraform_unused_declarations -variable "aws_account_id" { - description = "The AWS account ID" +variable "final_snapshot_identifier_prefix" { + description = "Prefix for the final snapshot name. When deletion is requested, a snapshot is created before deletion. Leave null to skip final snapshot." type = string - sensitive = true + default = null +} + +variable "data_tiering_enabled" { + description = <<-EOT + Enable data tiering (Redis 6.0+ with r6gd/r7gd instances only). + Allows overflow data to be stored on local NVMe SSD for cost savings. + Default: false + EOT + type = bool + default = false } +# ================================================================ +# Networking +# ================================================================ + variable "vpc_id" { - description = "The ID for the VPC" + description = "VPC ID. Required when create_security_group = true; otherwise optional." type = string + default = null } variable "subnet_ids" { - description = "The subnets that will be used for elasticache, usually private" + description = <<-EOT + List of subnet IDs for the ElastiCache subnet group. + Should be private subnets across multiple AZs for high availability. + Minimum: 2 subnets (different AZs); recommended for multi-AZ deployments. + EOT + type = list(string) + + validation { + condition = length(var.subnet_ids) >= 1 + error_message = "At least one subnet ID must be provided" + } +} + +variable "create_security_group" { + description = <<-EOT + When false (default), supply existing security group IDs via security_group_ids — e.g. + from this repo's security-group module (feature/BCSS-23606-security-group-module). + When true, the upstream module creates a security group in var.vpc_id using the + rules defined in security_group_rules. vpc_id is required in this case. + EOT + type = bool + default = false +} + +variable "security_group_ids" { + description = <<-EOT + List of existing security group IDs to associate with the cache. + Required when create_security_group = false. + Typically sourced from this repo's security-group module. + EOT type = list(string) + default = [] +} + +variable "security_group_rules" { + description = <<-EOT + Map of ingress and egress rules for the upstream-managed security group. + Only used when create_security_group = true. + See the upstream module documentation for the full shape of each rule entry. + EOT + type = any + default = {} +} + +# ================================================================ +# Maintenance & Monitoring +# ================================================================ + +variable "maintenance_window" { + description = <<-EOT + Time window for routine maintenance (UTC). + Format: ddd:hh24:mi-ddd:hh24:mi (e.g. 'sun:03:00-sun:05:00'). + Default: Sunday 03:00-05:00 UTC. + EOT + type = string + default = "sun:03:00-sun:05:00" + + validation { + condition = can(regex("^(mon|tue|wed|thu|fri|sat|sun):[0-2][0-9]:[0-5][0-9]-(mon|tue|wed|thu|fri|sat|sun):[0-2][0-9]:[0-5][0-9]$", lower(var.maintenance_window))) + error_message = "maintenance_window must be in format ddd:hh24:mi-ddd:hh24:mi" + } } -variable "ecs_sg_id" { - description = "The id of the ECS security group to enable access for" +variable "notification_topic_arn" { + description = "Optional SNS topic ARN for ElastiCache event notifications (failovers, updates, maintenance). Leave null to disable." type = string + default = null } -variable "create_elasticache_service_role" { - description = "The service role can only be created once per account, only enable it in one stack" +variable "apply_immediately" { + description = "Apply parameter changes immediately instead of during the maintenance window. Default: false (safer, batches changes)." type = bool - default = true + default = false +} + +# ================================================================ +# Logging +# ================================================================ + +variable "log_delivery_configuration" { + description = <<-EOT + Log delivery configuration passed to the upstream module. + By default, both slow-log and engine-log are sent to CloudWatch Logs with + 365-day retention and JSON format. The upstream module creates the log groups. + To pre-create log groups externally (e.g. via the cloudwatch module for KMS-encrypted + groups or custom retention), set create_cloudwatch_log_group = false and supply + the destination group name per entry. Set to {} to disable all logging. + EOT + type = any + default = { + slow-log = { + destination_type = "cloudwatch-logs" + log_format = "json" + cloudwatch_log_group_retention_in_days = 365 + } + engine-log = { + destination_type = "cloudwatch-logs" + log_format = "json" + cloudwatch_log_group_retention_in_days = 365 + } + } +} + +variable "serverless_cache_usage_limits" { + description = <<-EOT + Optional capacity limits for serverless caches (deployment_mode = "serverless"). + Leave as {} for on-demand auto-scaling with no hard limits. Example: + serverless_cache_usage_limits = { + data_storage = { maximum = 100, unit = "GB" } + ecpu_per_second = { maximum = 5000 } + } + EOT + type = any + default = {} } diff --git a/infrastructure/modules/elasticache/versions.tf b/infrastructure/modules/elasticache/versions.tf index cb30fe5c..6dda813f 100644 --- a/infrastructure/modules/elasticache/versions.tf +++ b/infrastructure/modules/elasticache/versions.tf @@ -1,10 +1,14 @@ terraform { - required_version = ">= 1.13" + required_version = ">= 1.0" required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.42" + version = ">= 5.93" + } + random = { + source = "hashicorp/random" + version = ">= 3.0" } } } From 45348010e17581469dac38e7f7f6a0c9d39f2173 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 04:21:49 +0100 Subject: [PATCH 2/6] feat(gitleaks): enhance gitleaks configuration for IPv4 and IPv6 rules --- .gitleaksignore | 4 ++++ scripts/config/gitleaks.toml | 27 +++++++++++++++++++++++++-- 2 files changed, 29 insertions(+), 2 deletions(-) diff --git a/.gitleaksignore b/.gitleaksignore index f97f5c8a..bf9d628a 100644 --- a/.gitleaksignore +++ b/.gitleaksignore @@ -5,3 +5,7 @@ e876843351a025eb754ec61982c8b7d95deeb709:.pre-commit-config.yaml:ipv4:119 e364bc1869c67729653c7efb4d6169f2294e68de:.pre-commit-config.yaml:ipv4:110 62088509f98ce02ce379adef2168b867eecfb5da:.pre-commit-config.yaml:ipv4:110 a3fa25da4e8f9eaa2e28c29f6196f23bfe87a58d:.pre-commit-config.yaml:ipv4:119 +# Historical false positive: example ARN comment in tags/main.tf contained hex-like content +# which triggered the ipv6 rule. Comment updated in later commit; old commits suppressed here. +7b49758d98757e8f404cb2c540c1f146afd6e395:infrastructure/modules/tags/main.tf:ipv6:131 +091dcd76884ffd307aee6c6b306b015c065f4896:infrastructure/modules/tags/main.tf:ipv6:131 diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index af5f0bb7..8371dcbc 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -11,8 +11,31 @@ regex = '''[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}''' [rules.allowlist] regexTarget = "match" regexes = [ - # Exclude the private network IPv4 addresses as well as the DNS servers for Google and OpenDNS - '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|208\.67\.222\.222|208\.67\.220\.220)''', + # Exclude private/reserved IPv4 addresses and well-known DNS servers used in docs/examples. + # Includes RFC5737 TEST-NET ranges: 192.0.2.0/24, 198.51.100.0/24, 203.0.113.0/24 + '''(127\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|10\.[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}|172\.(1[6-9]|2[0-9]|3[0-1])\.[0-9]{1,3}\.[0-9]{1,3}|192\.168\.[0-9]{1,3}\.[0-9]{1,3}|192\.0\.2\.[0-9]{1,3}|198\.51\.100\.[0-9]{1,3}|203\.0\.113\.[0-9]{1,3}|0\.0\.0\.0|255\.255\.255\.255|8\.8\.8\.8|8\.8\.4\.4|1\.1\.1\.1|1\.0\.0\.1)''', +] + +[[rules]] +description = "IPv6" +id = "ipv6" +# Matches valid IPv6 forms requiring at least 2 groups on each side of :: to +# avoid false positives from AWS ARNs (which use :: between region and account). +# full: 2001:0db8:85a3:0000:0000:8a2e:0370:7334 +# compressed: 2001:db8::1, fe80:db8::1 +# trailing :: fe80:db8:: (2+ groups required before ::) +# leading :: ::db8:1 (2+ groups required after ::) +# Note: RE2 does not support lookahead/lookbehind so boundary enforcement is +# achieved structurally via minimum repetition counts. +regex = '''(?i)(?:[0-9a-f]{1,4}:){7}[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){2,7}:|(?:[0-9a-f]{1,4}:){1,6}:[0-9a-f]{1,4}|(?:[0-9a-f]{1,4}:){1,5}(?::[0-9a-f]{1,4}){1,2}|(?:[0-9a-f]{1,4}:){1,4}(?::[0-9a-f]{1,4}){1,3}|(?:[0-9a-f]{1,4}:){1,3}(?::[0-9a-f]{1,4}){1,4}|(?:[0-9a-f]{1,4}:){1,2}(?::[0-9a-f]{1,4}){1,5}|[0-9a-f]{1,4}:(?::[0-9a-f]{1,4}){1,6}|:(?::[0-9a-f]{1,4}){2,7}''' + +[rules.allowlist] +regexTarget = "match" +regexes = [ + # Exclude IPv6 documentation prefixes used in examples. + # RFC3849: 2001:db8::/32 + # RFC9637: 3fff::/20 (3fff:0000:: to 3fff:0fff::) + '''(?i)(^|[^0-9a-f])(2001:db8:|3fff:0[0-9a-f]{0,3}:)''', ] [allowlist] From 6ce2ce5ab2a70da1a36b0b1dde9bc8be66db54c7 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 04:22:16 +0100 Subject: [PATCH 3/6] docs(vale): update word patterns/spellings for case sensitivity and add new terms --- .../vale/styles/config/vocabularies/words/accept.txt | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 1e45aa2a..51739457 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -22,6 +22,7 @@ callrecall [Cc]ognito [Cc]ron checkdigit +CloudWatch Concat commitlint config @@ -31,6 +32,7 @@ Dependabot Dockerfile domain_name_prefix en +[Ff]ailover full[Bb]uild Fargate function_name @@ -39,6 +41,8 @@ GitHub Gitleaks Grype handler_prefix +hardcode +hardocded healthcheck http_method idempotence @@ -58,6 +62,7 @@ name_prefix ni nonfemale npm +NVMe OAuth Octokit onboarding @@ -75,6 +80,7 @@ rbac_role [Rr]eachability readonly recovery_window +replication_group repo role_name [Ss]martcard @@ -82,6 +88,8 @@ role_name [Ss]ql Splunk sed +[Ss]harded +sharding shellcheck shortcode source_account_name @@ -95,12 +103,14 @@ Syft terraform-docs [Tt]utum tflint +[Tt]iering terraform-aws-modules toolchain tracing_mode Trufflehog URL url +[Vv]alkey vault_lock_type vault_name (VPC|[Vv]pc) From 845ec31703ef51b66a4bcb04e0bc0fffd2f519c1 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 04:25:22 +0100 Subject: [PATCH 4/6] feat(elasticache): update ElastiCache module to include parameter group family and enhance logging configuration --- infrastructure/modules/elasticache/README.md | 29 +++++++++---------- infrastructure/modules/elasticache/locals.tf | 3 ++ infrastructure/modules/elasticache/main.tf | 6 +++- infrastructure/modules/elasticache/outputs.tf | 2 +- .../modules/elasticache/variables.tf | 11 ------- 5 files changed, 23 insertions(+), 28 deletions(-) diff --git a/infrastructure/modules/elasticache/README.md b/infrastructure/modules/elasticache/README.md index f1be64f7..e3887532 100644 --- a/infrastructure/modules/elasticache/README.md +++ b/infrastructure/modules/elasticache/README.md @@ -264,7 +264,7 @@ module "page_cache" { - **Encryption**: Both in-transit (TLS) and at-rest encryption are **enforced and cannot be disabled**. Auth tokens required for Redis/Valkey. - **Cluster mode**: Enabled by default within `replication_group` mode. Each shard holds the full dataset; scales out by adding shards. - **Replicas**: Default 2 per shard. Set `replicas_per_node_group = 0` only for non-critical single-node deployments. -- **Logging**: Both slow-log and engine-log delivered to CloudWatch with 365-day retention by default. Override via `log_delivery_configuration`. Pre-create log groups via the cloudwatch module for KMS encryption. +- **Logging**: Both slow-log and engine-log delivered to CloudWatch with 365-day retention by default. Override via `log_delivery_configuration`. Pre-create log groups via the CloudWatch module for KMS encryption. - **Snapshots**: Redis/Valkey only (not Memcached). Default 5-day retention. Set `final_snapshot_identifier_prefix` to preserve state on deletion. - **Maintenance**: Applied during the configured window (default: Sunday 03:00–05:00 UTC). Use `apply_immediately = true` for emergency patches only. @@ -283,18 +283,18 @@ module "page_cache" { Before deploying, verify: -- [x] Encryption in transit enforced (TLS) -- [x] Encryption at rest enforced -- [x] No public access (private subnets + security groups) -- [x] Multi-AZ enabled for production -- [x] Automatic failover enabled for HA -- [x] Auth token set for Redis/Valkey (stored in Secrets Manager) -- [x] Allowed security groups/CIDRs explicitly defined -- [x] Logging enabled with KMS encryption -- [x] Snapshots enabled with retention policy -- [x] Maintenance window set to low-traffic time -- [x] SNS topic for notifications configured -- [x] All resources tagged via context module +- [ ] Encryption in transit enforced (TLS) — hardcoded, no action needed +- [ ] Encryption at rest enforced — hardcoded, no action needed +- [ ] No public access — confirm private subnets only in `subnet_ids` +- [ ] Multi-AZ enabled — set `multi_az_enabled = true` for production +- [ ] Automatic failover enabled — set `automatic_failover_enabled = true` for production +- [ ] Auth token supplied for Redis/Valkey — source from AWS Secrets Manager +- [ ] Security group configured — supply via `security_group_ids` or set `create_security_group = true` +- [ ] Logging configured — review `log_delivery_configuration` default (365-day retention) +- [ ] Snapshot retention set — review `snapshot_retention_days` (default: 5) +- [ ] Maintenance window configured for low-traffic period +- [ ] SNS topic supplied for failover/maintenance notifications +- [ ] All context labels set (`service`, `project`, `environment`, `name`) ## Outputs @@ -422,7 +422,6 @@ No resources. | [subnet\_ids](#input\_subnet\_ids) | List of subnet IDs for the ElastiCache subnet group.
Should be private subnets across multiple AZs for high availability.
Minimum: 2 subnets (different AZs); recommended for multi-AZ deployments. | `list(string)` | n/a | yes | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | -| [tls\_version](#input\_tls\_version) | TLS version for encryption in transit. Valid: 'plaintext', 'tls'. Default: 'tls' (enforced). | `string` | `"tls"` | no | | [vpc\_id](#input\_vpc\_id) | VPC ID. Required when create\_security\_group = true; otherwise optional. | `string` | `null` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | @@ -436,7 +435,7 @@ No resources. | [cluster\_arn](#output\_cluster\_arn) | ARN of the standalone ElastiCache cluster. | | [cluster\_configuration\_endpoint](#output\_cluster\_configuration\_endpoint) | Configuration endpoint for Memcached clusters (auto-discovery). | | [configuration\_endpoint\_address](#output\_configuration\_endpoint\_address) | Configuration endpoint for cluster-mode replication groups (connects to all shards). | -| [id](#output\_id) | Active deployment mode: replication\_group, cluster, or serverless. | +| [deployment\_mode](#output\_deployment\_mode) | Active deployment mode: replication\_group, cluster, or serverless. | | [maintenance\_window](#output\_maintenance\_window) | Maintenance window (UTC). | | [member\_clusters](#output\_member\_clusters) | List of member node IDs in the replication group. | | [port](#output\_port) | Port on which the ElastiCache resource listens. | diff --git a/infrastructure/modules/elasticache/locals.tf b/infrastructure/modules/elasticache/locals.tf index 0e92f973..39dc9053 100644 --- a/infrastructure/modules/elasticache/locals.tf +++ b/infrastructure/modules/elasticache/locals.tf @@ -19,6 +19,9 @@ locals { # ================================================================ major_engine_version = split(".", var.engine_version)[0] + # ElastiCache parameter group family — e.g. valkey8, redis7, memcached1.6 + parameter_family = var.engine == "memcached" ? "memcached${split(".", var.engine_version)[0]}.${split(".", var.engine_version)[1]}" : "${lower(var.engine)}${split(".", var.engine_version)[0]}" + # Extract HH:MM from snapshot_window (format hh:mi-hh:mi) for the serverless # daily_snapshot_time argument which expects HH:MM format. serverless_snapshot_time = var.snapshot_window != null ? split("-", var.snapshot_window)[0] : null diff --git a/infrastructure/modules/elasticache/main.tf b/infrastructure/modules/elasticache/main.tf index cfc85834..f8746078 100644 --- a/infrastructure/modules/elasticache/main.tf +++ b/infrastructure/modules/elasticache/main.tf @@ -57,6 +57,9 @@ module "elasticache" { replicas_per_node_group = var.cluster_mode_enabled ? var.replicas_per_node_group : null num_node_groups = var.cluster_mode_enabled ? var.num_node_groups : null + # Parameter group family (e.g. valkey8, redis7, memcached1.6) + parameter_group_family = local.parameter_family + # ---------------------------------------------------------------- # Resource identifiers # ---------------------------------------------------------------- @@ -66,9 +69,10 @@ module "elasticache" { # ---------------------------------------------------------------- # Encryption: in transit (TLS) and at rest — ENFORCED + # Neither is exposed as a variable; callers cannot weaken these. # ---------------------------------------------------------------- transit_encryption_enabled = true - transit_encryption_mode = var.tls_version + transit_encryption_mode = "required" at_rest_encryption_enabled = true kms_key_arn = var.kms_key_arn diff --git a/infrastructure/modules/elasticache/outputs.tf b/infrastructure/modules/elasticache/outputs.tf index b30221ab..b35c3f9b 100644 --- a/infrastructure/modules/elasticache/outputs.tf +++ b/infrastructure/modules/elasticache/outputs.tf @@ -1,4 +1,4 @@ -output "id" { +output "deployment_mode" { description = "Active deployment mode: replication_group, cluster, or serverless." value = var.deployment_mode } diff --git a/infrastructure/modules/elasticache/variables.tf b/infrastructure/modules/elasticache/variables.tf index 2abe3c28..7aa9174b 100644 --- a/infrastructure/modules/elasticache/variables.tf +++ b/infrastructure/modules/elasticache/variables.tf @@ -166,17 +166,6 @@ variable "port" { # Encryption (enforced) # ================================================================ -variable "tls_version" { - description = "TLS version for encryption in transit. Valid: 'plaintext', 'tls'. Default: 'tls' (enforced)." - type = string - default = "tls" - - validation { - condition = contains(["plaintext", "tls"], var.tls_version) - error_message = "tls_version must be 'plaintext' or 'tls'; only 'tls' recommended" - } -} - variable "kms_key_arn" { description = <<-EOT Optional KMS key ARN for encryption at rest. When null, AWS-managed encryption is used. From e88ef80331104373090f6407b80d8ea344cc59eb Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 10:27:58 +0100 Subject: [PATCH 5/6] fix(elasticache): align context.tf file --- infrastructure/modules/elasticache/README.md | 9 ++ infrastructure/modules/elasticache/context.tf | 83 +++++++++++++++++-- 2 files changed, 86 insertions(+), 6 deletions(-) diff --git a/infrastructure/modules/elasticache/README.md b/infrastructure/modules/elasticache/README.md index e3887532..06971e23 100644 --- a/infrastructure/modules/elasticache/README.md +++ b/infrastructure/modules/elasticache/README.md @@ -374,6 +374,7 @@ No resources. | Name | Description | Type | Default | Required | | ---- | ----------- | ---- | ------- | :------: | | [additional\_tag\_map](#input\_additional\_tag\_map) | Additional key-value pairs to add to each map in `tags_as_list_of_maps`. Not added to `tags` or `id`.
This is for some rare cases where resources want additional configuration of tags
and therefore take a list of maps with tag key, value, and additional configuration. | `map(string)` | `{}` | no | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | | [apply\_immediately](#input\_apply\_immediately) | Apply parameter changes immediately instead of during the maintenance window. Default: false (safer, batches changes). | `bool` | `false` | no | | [attributes](#input\_attributes) | ID element. Additional attributes (e.g. `workers` or `cluster`) to add to `id`,
in the order they appear in the list. New attributes are appended to the
end of the list. The elements of the list are joined by the `delimiter`
and treated as a single ID element. | `list(string)` | `[]` | no | | [auth\_token](#input\_auth\_token) | Authentication token for Redis/Valkey clusters (16-128 characters, alphanumeric only).
Required for Redis/Valkey; ignored for Memcached.
Rotate regularly; use AWS Secrets Manager or similar. | `string` | `null` | no | @@ -384,7 +385,9 @@ No resources. | [cluster\_mode\_enabled](#input\_cluster\_mode\_enabled) | Enable cluster mode (sharding). When true, all nodes in each shard store the full dataset.
Allows horizontal scaling via multiple shards.
Default: true (recommended for production). | `bool` | `true` | no | | [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | | [create\_security\_group](#input\_create\_security\_group) | When false (default), supply existing security group IDs via security\_group\_ids — e.g.
from this repo's security-group module (feature/BCSS-23606-security-group-module).
When true, the upstream module creates a security group in var.vpc\_id using the
rules defined in security\_group\_rules. vpc\_id is required in this case. | `bool` | `false` | no | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | | [data\_tiering\_enabled](#input\_data\_tiering\_enabled) | Enable data tiering (Redis 6.0+ with r6gd/r7gd instances only).
Allows overflow data to be stored on local NVMe SSD for cost savings.
Default: false | `bool` | `false` | no | +| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | | [delimiter](#input\_delimiter) | Delimiter to be used between ID elements.
Defaults to `-` (hyphen). Set to `""` to use no delimiter at all. | `string` | `null` | no | | [deployment\_mode](#input\_deployment\_mode) | Controls which ElastiCache resource type is created.
- replication\_group (default): HA replication group with optional cluster mode and Multi-AZ.
Supports Valkey, Redis, and Memcached. Recommended for production and session storage.
- cluster: Standalone single/multi-node cluster without replication.
Useful for development, non-prod cost saving, or Memcached deployments.
- serverless: Auto-scaling serverless cache. Valkey and Redis only.
No node provisioning required; capacity scales on demand.
Note: Memcached only supports deployment\_mode = "cluster". | `string` | `"replication_group"` | no | | [descriptor\_formats](#input\_descriptor\_formats) | Describe additional descriptors to be output in the `descriptors` output map.
Map of maps. Keys are names of descriptors. Values are maps of the form
`{
format = string
labels = list(string)
}`
(Type is `any` so the map values can later be enhanced to provide additional options.)
`format` is a Terraform format string to be passed to the `format()` function.
`labels` is a list of labels, in order, to pass to `format()` function.
Label values will be normalized before being passed to `format()` so they will be
identical to how they appear in `id`.
Default is `{}` (`descriptors` output will be empty). | `any` | `{}` | no | @@ -407,8 +410,11 @@ No resources. | [notification\_topic\_arn](#input\_notification\_topic\_arn) | Optional SNS topic ARN for ElastiCache event notifications (failovers, updates, maintenance). Leave null to disable. | `string` | `null` | no | | [num\_cache\_nodes](#input\_num\_cache\_nodes) | Number of cache nodes in the cluster. For non-cluster mode only; ignored when cluster\_mode\_enabled = true. | `number` | `2` | no | | [num\_node\_groups](#input\_num\_node\_groups) | Number of shards in cluster mode. Only used when cluster\_mode\_enabled = true. Minimum 1, maximum 500. | `number` | `1` | no | +| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | | [port](#input\_port) | Port on which ElastiCache listens. Default: 6379 for Redis/Valkey, 11211 for Memcached. Must match between clients and cache. | `number` | `6379` | no | | [project](#input\_project) | ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api` | `string` | `null` | no | +| [public\_facing](#input\_public\_facing) | Whether this resource is public facing | `bool` | `false` | no | | [regex\_replace\_chars](#input\_regex\_replace\_chars) | Terraform regular expression (regex) string.
Characters matching the regex will be removed from the ID elements.
If not set, `"/[^a-zA-Z0-9-]/"` is used to remove all characters other than hyphens, letters and digits. | `string` | `null` | no | | [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no | | [replicas\_per\_node\_group](#input\_replicas\_per\_node\_group) | Number of replicas per shard (cluster mode) or per replication group (disabled cluster mode).
Each replica stores a copy of the dataset for high availability and failover.
Default: 2 (recommended for multi-AZ production deployments). | `number` | `2` | no | @@ -416,12 +422,15 @@ No resources. | [security\_group\_rules](#input\_security\_group\_rules) | Map of ingress and egress rules for the upstream-managed security group.
Only used when create\_security\_group = true.
See the upstream module documentation for the full shape of each rule entry. | `any` | `{}` | no | | [serverless\_cache\_usage\_limits](#input\_serverless\_cache\_usage\_limits) | Optional capacity limits for serverless caches (deployment\_mode = "serverless").
Leave as {} for on-demand auto-scaling with no hard limits. Example:
serverless\_cache\_usage\_limits = {
data\_storage = { maximum = 100, unit = "GB" }
ecpu\_per\_second = { maximum = 5000 }
} | `any` | `{}` | no | | [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no | +| [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no | | [snapshot\_retention\_days](#input\_snapshot\_retention\_days) | Number of days to retain automated snapshots (Redis/Valkey only).
Memcached does not support snapshots.
Range: 1 to 35 days. Default: 5 days. | `number` | `5` | no | | [snapshot\_window](#input\_snapshot\_window) | Time window in UTC when snapshots are taken (Redis/Valkey only).
Format: hh24:mi-hh24:mi (e.g. '03:00-05:00'). Default: '03:00-05:00' | `string` | `"03:00-05:00"` | no | | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [subnet\_ids](#input\_subnet\_ids) | List of subnet IDs for the ElastiCache subnet group.
Should be private subnets across multiple AZs for high availability.
Minimum: 2 subnets (different AZs); recommended for multi-AZ deployments. | `list(string)` | n/a | yes | +| [tag\_version](#input\_tag\_version) | Used to identify the tagging version in use | `string` | `"1.0"` | no | | [tags](#input\_tags) | Additional tags (e.g. `{'BusinessUnit': 'XYZ'}`).
Neither the tag keys nor the tag values will be modified by this module. | `map(string)` | `{}` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to the caller module path when not set. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [vpc\_id](#input\_vpc\_id) | VPC ID. Required when create\_security\_group = true; otherwise optional. | `string` | `null` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | diff --git a/infrastructure/modules/elasticache/context.tf b/infrastructure/modules/elasticache/context.tf index e921c83b..e934a84f 100644 --- a/infrastructure/modules/elasticache/context.tf +++ b/infrastructure/modules/elasticache/context.tf @@ -22,7 +22,6 @@ # module "this" { - # tflint-ignore: terraform_module_pinned_source source = "../tags" enabled = var.enabled @@ -83,7 +82,14 @@ variable "context" { label_value_case = null terraform_source = null descriptor_formats = {} - labels_as_tags = ["unset"] + # Note: we have to use [] instead of null for unset lists due to + # https://github.com/hashicorp/terraform/issues/28137 + # which was not fixed until Terraform 1.0.0, + # but we want the default to be all the labels in `label_order` + # and we want users to be able to prevent all tag generation + # by setting `labels_as_tags` to `[]`, so we need + # a different sentinel to indicate "default" + labels_as_tags = ["unset"] } description = <<-EOT Single object for setting entire context at once. @@ -133,19 +139,16 @@ variable "project" { default = null description = "ID element. A project identifier, indicating the name or role of the project the resource is for, such as `website` or `api`" } - variable "stack" { type = string default = null description = "ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks`" } - variable "workspace" { type = string default = null description = "ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces" } - variable "environment" { type = string default = null @@ -301,5 +304,73 @@ variable "descriptor_formats" { Label values will be normalized before being passed to `format()` so they will be identical to how they appear in `id`. Default is `{}` (`descriptors` output will be empty). - EOT + EOT +} + +variable "owner" { + type = string + description = "The name and or NHS.net email address of the service owner" + default = "None" +} + +variable "tag_version" { + type = string + description = "Used to identify the tagging version in use" + default = "1.0" +} + +variable "data_classification" { + type = string + description = "Used to identify the data classification of the resource, e.g 1-5" + default = "n/a" + validation { + condition = contains(["n/a", "1", "2", "3", "4", "5"], var.data_classification) + error_message = "Data Classification must be \"n/a\" or between 1-5" + } } + +variable "data_type" { + type = string + description = "The tag data_type" + default = "None" + validation { + condition = contains(["None", "PCD", "PID", "Anonymised", "UserAccount", "Audit"], var.data_type) + error_message = "Data Type must be one of None, PCD, PID, Anonymised, UserAccount, Audit" + } +} + + +variable "public_facing" { + type = bool + description = "Whether this resource is public facing" + default = false +} + +variable "service_category" { + type = string + description = "The tag service_category" + default = "n/a" + validation { + condition = contains(["n/a", "Bronze", "Silver", "Gold", "Platinum"], var.service_category) + error_message = "The Service Category must be one of n/a, Bronze, Silver, Gold, Platinum" + } +} +variable "on_off_pattern" { + type = string + description = "Used to turn resources on and off based on a time pattern" + default = "n/a" +} + +variable "application_role" { + type = string + description = "The role the application is performing" + default = "General" +} + +variable "tool" { + type = string + description = "The tool used to deploy the resource" + default = "Terraform" +} + +#### End of copy of screening-terraform-modules-aws/tags/variables.tf From 01ce9c63fe04dbefc0a20780a230e08c4fe9bc0b Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 10:30:16 +0100 Subject: [PATCH 6/6] refactor(elasticache): remove security group management variables and simplify security group handling --- infrastructure/modules/elasticache/README.md | 4 +--- infrastructure/modules/elasticache/main.tf | 13 ++---------- infrastructure/modules/elasticache/outputs.tf | 5 ++--- .../modules/elasticache/variables.tf | 21 ------------------- 4 files changed, 5 insertions(+), 38 deletions(-) diff --git a/infrastructure/modules/elasticache/README.md b/infrastructure/modules/elasticache/README.md index 06971e23..edbe5999 100644 --- a/infrastructure/modules/elasticache/README.md +++ b/infrastructure/modules/elasticache/README.md @@ -384,7 +384,6 @@ No resources. | [az\_mode](#input\_az\_mode) | Availability zone mode for standalone clusters (deployment\_mode = "cluster").
- single-az (default): all nodes in one AZ.
- cross-az: nodes spread across multiple AZs. Required for Memcached multi-node clusters.
Ignored for replication\_group and serverless deployment modes. | `string` | `null` | no | | [cluster\_mode\_enabled](#input\_cluster\_mode\_enabled) | Enable cluster mode (sharding). When true, all nodes in each shard store the full dataset.
Allows horizontal scaling via multiple shards.
Default: true (recommended for production). | `bool` | `true` | no | | [context](#input\_context) | Single object for setting entire context at once.
See description of individual variables for details.
Leave string and numeric variables as `null` to use default value.
Individual variable settings (non-null) override settings in context object,
except for attributes, tags, and additional\_tag\_map, which are merged. | `any` |
{
"additional_tag_map": {},
"attributes": [],
"delimiter": null,
"descriptor_formats": {},
"enabled": true,
"environment": null,
"id_length_limit": null,
"label_key_case": null,
"label_order": [],
"label_value_case": null,
"labels_as_tags": [
"unset"
],
"name": null,
"project": null,
"regex_replace_chars": null,
"region": null,
"service": null,
"stack": null,
"tags": {},
"terraform_source": null,
"workspace": null
}
| no | -| [create\_security\_group](#input\_create\_security\_group) | When false (default), supply existing security group IDs via security\_group\_ids — e.g.
from this repo's security-group module (feature/BCSS-23606-security-group-module).
When true, the upstream module creates a security group in var.vpc\_id using the
rules defined in security\_group\_rules. vpc\_id is required in this case. | `bool` | `false` | no | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | | [data\_tiering\_enabled](#input\_data\_tiering\_enabled) | Enable data tiering (Redis 6.0+ with r6gd/r7gd instances only).
Allows overflow data to be stored on local NVMe SSD for cost savings.
Default: false | `bool` | `false` | no | | [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | @@ -419,7 +418,6 @@ No resources. | [region](#input\_region) | ID element \_(Rarely used, not included by default)\_. Usually an abbreviation of the selected AWS region e.g. 'uw2', 'ew2' or 'gbl' for resources like IAM roles that have no region | `string` | `null` | no | | [replicas\_per\_node\_group](#input\_replicas\_per\_node\_group) | Number of replicas per shard (cluster mode) or per replication group (disabled cluster mode).
Each replica stores a copy of the dataset for high availability and failover.
Default: 2 (recommended for multi-AZ production deployments). | `number` | `2` | no | | [security\_group\_ids](#input\_security\_group\_ids) | List of existing security group IDs to associate with the cache.
Required when create\_security\_group = false.
Typically sourced from this repo's security-group module. | `list(string)` | `[]` | no | -| [security\_group\_rules](#input\_security\_group\_rules) | Map of ingress and egress rules for the upstream-managed security group.
Only used when create\_security\_group = true.
See the upstream module documentation for the full shape of each rule entry. | `any` | `{}` | no | | [serverless\_cache\_usage\_limits](#input\_serverless\_cache\_usage\_limits) | Optional capacity limits for serverless caches (deployment\_mode = "serverless").
Leave as {} for on-demand auto-scaling with no hard limits. Example:
serverless\_cache\_usage\_limits = {
data\_storage = { maximum = 100, unit = "GB" }
ecpu\_per\_second = { maximum = 5000 }
} | `any` | `{}` | no | | [service](#input\_service) | ID element. Usually an abbreviation of your service directorate name, e.g. 'bcss' or 'csms', to help ensure generated IDs are globally unique | `string` | `null` | no | | [service\_category](#input\_service\_category) | The tag service\_category | `string` | `"n/a"` | no | @@ -452,7 +450,7 @@ No resources. | [reader\_endpoint\_address](#output\_reader\_endpoint\_address) | Reader endpoint (load-balanced across replicas) for the replication group. | | [replication\_group\_arn](#output\_replication\_group\_arn) | ARN of the ElastiCache replication group. | | [replication\_group\_id](#output\_replication\_group\_id) | ID of the ElastiCache replication group. | -| [security\_group\_id](#output\_security\_group\_id) | First security group ID associated with the cache.
When create\_security\_group = false this is security\_group\_ids[0] (caller-managed).
When create\_security\_group = true, query the SG from the security-group module instead. | +| [security\_group\_id](#output\_security\_group\_id) | First security group ID associated with the cache.
This is security\_group\_ids[0] (caller-managed). | | [serverless\_arn](#output\_serverless\_arn) | ARN of the serverless cache. | | [serverless\_endpoint](#output\_serverless\_endpoint) | Connection endpoint (address and port) for the serverless cache. | | [serverless\_reader\_endpoint](#output\_serverless\_reader\_endpoint) | Reader endpoint for the serverless cache. | diff --git a/infrastructure/modules/elasticache/main.tf b/infrastructure/modules/elasticache/main.tf index f8746078..8df4da5f 100644 --- a/infrastructure/modules/elasticache/main.tf +++ b/infrastructure/modules/elasticache/main.tf @@ -111,17 +111,8 @@ module "elasticache" { vpc_id = var.vpc_id # Security group: - # Option A — pass existing SG IDs from the security-group module - # (feature/BCSS-23606-security-group-module): - # create_security_group = false - # security_group_ids = [module.cache_sg.id] - # Option B — let the upstream module create one inline: - # create_security_group = true - # security_group_rules = { ... } - create_security_group = var.create_security_group + create_security_group = false security_group_ids = var.security_group_ids - security_group_rules = var.security_group_rules - security_group_tags = module.this.tags # Port configuration port = var.port @@ -131,7 +122,7 @@ module "elasticache" { # ---------------------------------------------------------------- # Delegated to upstream module built-in log group creation. # - # TODO: Pre-create log groups via the cloudwatch module for stronger + # TODO: Pre-create log groups via the cloudwatch module for stronger # control over KMS key, retention class, and skip_destroy behaviour. Example # using terraform-aws-modules/cloudwatch/aws//modules/log-group: # diff --git a/infrastructure/modules/elasticache/outputs.tf b/infrastructure/modules/elasticache/outputs.tf index b35c3f9b..a381b4d6 100644 --- a/infrastructure/modules/elasticache/outputs.tf +++ b/infrastructure/modules/elasticache/outputs.tf @@ -87,10 +87,9 @@ output "serverless_reader_endpoint" { output "security_group_id" { description = <<-EOT First security group ID associated with the cache. - When create_security_group = false this is security_group_ids[0] (caller-managed). - When create_security_group = true, query the SG from the security-group module instead. + This is security_group_ids[0] (caller-managed). EOT - value = module.this.enabled && !var.create_security_group && length(var.security_group_ids) > 0 ? var.security_group_ids[0] : null + value = module.this.enabled && length(var.security_group_ids) > 0 ? var.security_group_ids[0] : null } # ================================================================ diff --git a/infrastructure/modules/elasticache/variables.tf b/infrastructure/modules/elasticache/variables.tf index 7aa9174b..beeb6a81 100644 --- a/infrastructure/modules/elasticache/variables.tf +++ b/infrastructure/modules/elasticache/variables.tf @@ -260,17 +260,6 @@ variable "subnet_ids" { } } -variable "create_security_group" { - description = <<-EOT - When false (default), supply existing security group IDs via security_group_ids — e.g. - from this repo's security-group module (feature/BCSS-23606-security-group-module). - When true, the upstream module creates a security group in var.vpc_id using the - rules defined in security_group_rules. vpc_id is required in this case. - EOT - type = bool - default = false -} - variable "security_group_ids" { description = <<-EOT List of existing security group IDs to associate with the cache. @@ -281,16 +270,6 @@ variable "security_group_ids" { default = [] } -variable "security_group_rules" { - description = <<-EOT - Map of ingress and egress rules for the upstream-managed security group. - Only used when create_security_group = true. - See the upstream module documentation for the full shape of each rule entry. - EOT - type = any - default = {} -} - # ================================================================ # Maintenance & Monitoring # ================================================================