From 4ce865f2b3b5fa8a67d03d43db834dde4c4b583d Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Fri, 5 Jun 2026 10:37:17 +0100 Subject: [PATCH 001/118] BCSS-23458 TERRAFORM MODULE: COGNITO --- infrastructure/modules/cognito/context.tf | 370 ++++++++++++++++++++ infrastructure/modules/cognito/main.tf | 182 +++++----- infrastructure/modules/cognito/outputs.tf | 64 +++- infrastructure/modules/cognito/readme.md | 134 +++++-- infrastructure/modules/cognito/variables.tf | 280 +++++++++++++-- infrastructure/modules/cognito/versions.tf | 15 + 6 files changed, 909 insertions(+), 136 deletions(-) create mode 100644 infrastructure/modules/cognito/context.tf create mode 100644 infrastructure/modules/cognito/versions.tf diff --git a/infrastructure/modules/cognito/context.tf b/infrastructure/modules/cognito/context.tf new file mode 100644 index 00000000..5d47db35 --- /dev/null +++ b/infrastructure/modules/cognito/context.tf @@ -0,0 +1,370 @@ +# +# 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" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.6.0" + + 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 +} + +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 diff --git a/infrastructure/modules/cognito/main.tf b/infrastructure/modules/cognito/main.tf index c9fe18dc..119351f0 100644 --- a/infrastructure/modules/cognito/main.tf +++ b/infrastructure/modules/cognito/main.tf @@ -1,67 +1,17 @@ -data "aws_ssm_parameter" "cognito_users" { - name = "/${var.name_prefix}/cognito/users" -} - locals { - userdata = jsondecode(nonsensitive(data.aws_ssm_parameter.cognito_users.value)) -} - -resource "random_password" "password" { - length = 20 - special = true - override_special = "!%^*-_+=" -} - -resource "aws_secretsmanager_secret" "password" { - name = "${var.name_prefix}-cognito-user" - recovery_window_in_days = var.recovery_window + user_pool_name = coalesce(var.user_pool_name, var.name_prefix != null ? "${var.name_prefix}-users-pool" : null, module.this.id) + domain_name = var.create_domain ? coalesce(var.domain, var.name_prefix, local.user_pool_name) : null + default_app_client_name = coalesce(var.name_prefix != null ? "${var.name_prefix}-users-client" : null, "${local.user_pool_name}-client") - dynamic "replica" { - for_each = var.secret_replication_regions - content { - region = replica.value - } - } -} - -resource "aws_secretsmanager_secret_version" "password" { - secret_id = aws_secretsmanager_secret.password.id - secret_string = jsonencode({ - password = random_password.password.result - }) -} - -# Create user pool -resource "aws_cognito_user_pool" "cognito_user_pool" { - auto_verified_attributes = [ - "email", - ] - - deletion_protection = var.deletion_protection - mfa_configuration = var.mfa_configuration - name = var.name_prefix - username_attributes = [] - - account_recovery_setting { - recovery_mechanism { - name = "verified_email" - priority = 1 - } - recovery_mechanism { - name = "verified_phone_number" - priority = 2 - } - } - - admin_create_user_config { + default_admin_create_user_config = { allow_admin_create_user_only = false } - email_configuration { + default_email_configuration = { email_sending_account = "COGNITO_DEFAULT" } - password_policy { + default_password_policy = { minimum_length = 8 require_lowercase = false require_numbers = false @@ -69,42 +19,114 @@ resource "aws_cognito_user_pool" "cognito_user_pool" { require_uppercase = false temporary_password_validity_days = 7 } - dynamic "schema" { - for_each = var.attribute_names - content { + + default_recovery_mechanisms = [ + { + name = "verified_email" + priority = 1 + }, + { + name = "verified_phone_number" + priority = 2 + } + ] + + default_string_schemas = [ + for attribute_name in var.attribute_names : { attribute_data_type = "String" developer_only_attribute = false mutable = true - name = schema.value + name = attribute_name required = false - - string_attribute_constraints { - max_length = "256" + string_attribute_constraints = { min_length = "1" + max_length = "256" } } - } - username_configuration { - case_sensitive = false - } + ] - verification_message_template { - default_email_option = "CONFIRM_WITH_CODE" - } + resolved_admin_create_user_config = var.admin_create_user_config != null ? var.admin_create_user_config : local.default_admin_create_user_config + resolved_email_configuration = var.email_configuration != null ? var.email_configuration : local.default_email_configuration + resolved_password_policy = var.password_policy != null ? var.password_policy : local.default_password_policy + resolved_recovery_mechanisms = length(var.recovery_mechanisms) > 0 ? var.recovery_mechanisms : local.default_recovery_mechanisms + resolved_string_schemas = length(var.string_schemas) > 0 ? var.string_schemas : local.default_string_schemas + resolved_username_configuration = var.username_configuration != null ? var.username_configuration : { case_sensitive = false } + resolved_verification_message_template = var.verification_message_template != null ? var.verification_message_template : { default_email_option = "CONFIRM_WITH_CODE" } + + cognito_clients = [ + for client in var.app_clients : { + name = client.name + callback_urls = client.callback_urls + logout_urls = client.logout_urls + default_redirect_uri = try(client.default_redirect_uri, null) + generate_secret = try(client.generate_secret, true) + auth_session_validity = try(client.auth_session_validity, 3) + allowed_oauth_flows_user_pool_client = try(client.allowed_oauth_flows_user_pool_client, true) + allowed_oauth_flows = try(client.allowed_oauth_flows, ["code"]) + allowed_oauth_scopes = try(client.allowed_oauth_scopes, ["email", "openid", "profile", "aws.cognito.signin.user.admin"]) + explicit_auth_flows = try(client.explicit_auth_flows, ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"]) + supported_identity_providers = try(client.supported_identity_providers, ["COGNITO"]) + enable_propagate_additional_user_context_data = try(client.enable_propagate_additional_user_context_data, false) + access_token_validity = try(client.access_token_validity, null) + id_token_validity = try(client.id_token_validity, null) + refresh_token_validity = try(client.refresh_token_validity, null) + token_validity_units = try(client.token_validity_units, { access_token = "minutes", id_token = "minutes", refresh_token = "days" }) + prevent_user_existence_errors = try(client.prevent_user_existence_errors, null) + read_attributes = try(client.read_attributes, []) + write_attributes = try(client.write_attributes, []) + enable_token_revocation = try(client.enable_token_revocation, true) + name = try(client.name, local.default_app_client_name) + } + ] } - -resource "aws_cognito_user_pool_domain" "main" { - domain = var.name_prefix - user_pool_id = aws_cognito_user_pool.cognito_user_pool.id +module "cognito" { + source = "lgallard/cognito-user-pool/aws" + version = "4.0.2" + + enabled = module.this.enabled && var.create + + user_pool_name = local.user_pool_name + user_pool_tier = var.user_pool_tier + domain = local.domain_name + domain_certificate_arn = var.domain_certificate_arn + domain_managed_login_version = var.domain_managed_login_version + deletion_protection = var.deletion_protection + alias_attributes = var.alias_attributes + username_attributes = var.username_attributes + auto_verified_attributes = var.auto_verified_attributes + mfa_configuration = var.mfa_configuration + + admin_create_user_config = local.resolved_admin_create_user_config + email_configuration = local.resolved_email_configuration + password_policy = local.resolved_password_policy + recovery_mechanisms = local.resolved_recovery_mechanisms + string_schemas = local.resolved_string_schemas + username_configuration = local.resolved_username_configuration + verification_message_template = local.resolved_verification_message_template + + user_pool_add_ons_advanced_security_mode = var.user_pool_add_ons_advanced_security_mode + user_pool_add_ons_advanced_security_additional_flows = var.user_pool_add_ons_advanced_security_additional_flows + sign_in_policy = var.sign_in_policy + software_token_mfa_configuration = var.software_token_mfa_configuration + sms_configuration = var.sms_configuration + email_mfa_configuration = var.email_mfa_configuration + user_attribute_update_settings = var.user_attribute_update_settings + lambda_config = var.lambda_config + clients = local.cognito_clients + managed_login_branding_enabled = var.managed_login_branding_enabled + managed_login_branding = var.managed_login_branding + ignore_schema_changes = var.ignore_schema_changes + + tags = module.this.tags } -resource "aws_cognito_user" "cognito_user_creation" { - for_each = { for inst in local.userdata : inst.uuid => inst } +resource "aws_cognito_user" "bootstrap_users" { + for_each = module.this.enabled && var.create ? { for user in var.bootstrap_users : user.uuid => user } : {} - user_pool_id = aws_cognito_user_pool.cognito_user_pool.id - username = each.value.bss_username - password = random_password.password.result + user_pool_id = module.cognito.id + username = each.value.bcss_username + password = coalesce(try(each.value.user_password, null), var.user_password) message_action = var.message_action attributes = { @@ -114,7 +136,7 @@ resource "aws_cognito_user" "cognito_user_creation" { email_verified = true idassurancelevel = each.value.id_assurance_level nhsid_nrbac_roles = each.value.rbac_role - bss_username = each.value.bss_username + bcss_username = each.value.bcss_username sid = each.value.uuid uid = each.value.uuid } diff --git a/infrastructure/modules/cognito/outputs.tf b/infrastructure/modules/cognito/outputs.tf index df19ed45..97f5e86a 100644 --- a/infrastructure/modules/cognito/outputs.tf +++ b/infrastructure/modules/cognito/outputs.tf @@ -1,11 +1,67 @@ output "user_pool_id" { - value = aws_cognito_user_pool.cognito_user_pool.id + description = "ID of the Cognito user pool." + value = module.cognito.id } -output "secrets_manager_random_passsword_arn" { - value = aws_secretsmanager_secret.password.arn +output "user_pool_arn" { + description = "ARN of the Cognito user pool." + value = module.cognito.arn +} + +output "user_pool_name" { + description = "Name of the Cognito user pool." + value = module.cognito.name +} + +output "user_pool_endpoint" { + description = "Cognito user pool endpoint." + value = module.cognito.endpoint } output "user_pool_domain_prefix" { - value = aws_cognito_user_pool_domain.main.domain + description = "Configured Cognito domain value when create_domain is enabled." + value = local.domain_name +} + +output "user_pool_hosted_ui_url" { + description = "Hosted UI URL for the Cognito domain when a default domain prefix is configured." + value = local.domain_name != null && var.domain_certificate_arn == null ? "https://${local.domain_name}.auth.${var.aws_region}.amazoncognito.com" : null +} + +output "client_ids" { + description = "IDs of any Cognito user pool clients created by this module." + value = module.cognito.client_ids +} + +output "client_ids_map" { + description = "Map of Cognito client names to client IDs." + value = module.cognito.client_ids_map +} + +output "app_client_ids" { + description = "Map of shared-resources app client names to client IDs." + value = module.cognito.client_ids_map +} + +output "client_secrets" { + description = "Secrets of any Cognito user pool clients created by this module." + value = module.cognito.client_secrets + sensitive = true +} + +output "client_secrets_map" { + description = "Map of Cognito client names to client secrets." + value = module.cognito.client_secrets_map + sensitive = true +} + +output "app_client_secrets" { + description = "Map of shared-resources app client names to client secrets." + value = module.cognito.client_secrets_map + sensitive = true +} + +output "secrets_manager_random_passsword_arn" { + description = "Deprecated compatibility output from the bespoke BS-Select bootstrap-user flow. This wrapper does not create a bootstrap user secret." + value = null } diff --git a/infrastructure/modules/cognito/readme.md b/infrastructure/modules/cognito/readme.md index a7769a92..9096e84f 100644 --- a/infrastructure/modules/cognito/readme.md +++ b/infrastructure/modules/cognito/readme.md @@ -6,45 +6,114 @@ This is a OAuth2 client that allows us to log into the BS-Select application usi same controls and security that CIS2 offers. We have the ability to control the configuration of the client, including the users available for logging in. -## Useful Values - -The Cognito client when created will be accessible via the following url: - -- - -```java -The following values are needed by the BS-Select application to connect to this Cognito instance: -|Value|Default Profile Value|Description| -|-----|---------------------|-----------| -|spring.security.oauth2.client.registration.nhs-identity.scope|email, openid, profile, aws.cognito.signin.user.admin|The scope used by OAuth2 for the users.| -|spring.security.oauth2.client.registration.nhs-identity.client-id|COGNITO_CLIENT_ID_TO_BE_REPLACED|The client ID for the Cognito user client instance.| -|spring.security.oauth2.client.registration.nhs-identity.client-secret|COGNITO_CLIENT_SECRET_TO_BE_REPLACED|The client secret for the Cognito user client instance.| -|spring.security.oauth2.client.registration.nhs-identity.redirect-uri|https:///bss/login/oauth2/code/nhs-identity|The redirect once authentication has been completed.| -|spring.security.oauth2.client.provider.nhs-identity.issuer-uri||The issuer-uri, the full URL is required but main value required is the ID of the Cognito user pool| -| spring.security.oauth2.client.provider.nhs-identity.cognito-domain ||The domain to direct to for login.| +# Cognito + +Thin wrapper around [`lgallard/cognito-user-pool/aws`](https://registry.terraform.io/modules/lgallard/cognito-user-pool/aws/4.0.2) +for the shared-resources stack. + +## Summary + +This module standardises Cognito user pool creation around the preferred upstream +community module while adopting this repository's shared `context.tf` pattern. +It keeps the default pool/domain/schema behaviour from the existing bespoke +module where practical, but deliberately avoids carrying forward the old +Secrets Manager password flow. + +## Design choices + +* Uses the upstream `lgallard/cognito-user-pool/aws` module pinned to `4.0.2` +* Derives the user pool name from `user_pool_name`, then `name_prefix`, then the + shared context-derived module ID +* Creates a Cognito domain by default, following the prior module behaviour +* Enables `ignore_schema_changes = true` by default because this is recommended + for new Cognito deployments with custom schemas +* Keeps a small compatibility layer for legacy inputs such as `name_prefix` and + `attribute_names` +* Narrows application client configuration to an `app_clients` interface instead + of exposing the upstream generic `clients`, `resource_servers`, `user_groups`, + and `identity_providers` inputs directly +* Supports optional bootstrap user creation because the current BCSS Cognito + stacks still provision initial users during stack deployment + +## What this module does not do + +* It does not create or replicate a Secrets Manager password secret +* It does not create the KMS keys or SSM parameters used by the older external + and training stacks; those remain stack-level concerns and can consume this + module's outputs instead + +## Usage + +```hcl +module "cognito" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cognito?ref=main" + + name = "cognito" + project = "shared" + environment = "dev" + + app_clients = [ + { + callback_urls = ["https://example.internal/login/oauth2/code/nhs-identity"] + logout_urls = ["https://example.internal/logout"] + default_redirect_uri = "https://example.internal/login/oauth2/code/nhs-identity" + + allowed_oauth_flows_user_pool_client = true + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = [ + "email", + "openid", + "profile", + "aws.cognito.signin.user.admin", + ] + supported_identity_providers = ["COGNITO"] + generate_secret = true + } + ] + + bootstrap_users = [ + { + uuid = "11111111-1111-1111-1111-111111111111" + bcss_username = "test.user" + id_assurance_level = "3" + rbac_role = "[{activities=[BS-Select], activity_codes=[B1808]}]" + } + ] +} ``` -## Creating users +## Current defaults inherited from the old module -Users for this Cognito client are managed via the users.csv file. The following values need to be -specified: +* `auto_verified_attributes = ["email"]` +* `mfa_configuration = "OFF"` +* `admin_create_user_config.allow_admin_create_user_only = false` +* `email_configuration.email_sending_account = "COGNITO_DEFAULT"` +* `verification_message_template.default_email_option = "CONFIRM_WITH_CODE"` +* `username_configuration.case_sensitive = false` +* `attribute_names` produces the same default custom string attributes as the old module +* `bootstrap_users` can be used to preserve the current stack behavior where Cognito users are created during deployment -| Column | Value | -| ------------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| UUID | The UUID associated with the user in the BS-Select database. If the user is not in the BS-Select database for the environment, login will fail. | -| bss_username | The BS-Select username associated with the user and the value used for the Username value in Cognito. | -| rbac_role | This replicates the roles CIS2 would provide by using a subset of the data provided. Use the following as the default value for a valid user: `"[{activities=[BS-Select], activity_codes=[B1808]}]"` | -| id_assurance_level | This replicates the assurance level that CIS2 would provide for the user. | +## Intentionally omitted upstream surface -When running the nonprod-shared infrastructure pipeline, all the users listed in the CSV file will be created (or modified if a change is made) and -will be automatically marked as being valid. All users are created with the same default password specified in the variables.tf file. +The wrapper does not directly expose the upstream generic inputs for: - - - -## Requirements +* identity providers +* resource servers +* user groups +* arbitrary client definitions beyond the curated `app_clients` OAuth client shape -No requirements. +If those become required later, they can be added back with an explicit shared-resources use case. + +## Proven BCSS compatibility notes + +Comparing against the current BCSS stacks showed that the module surface needs to cover: + +* user pool settings such as deletion protection, MFA, schema attributes, and username settings +* one or more OAuth app clients with callback URLs, logout URLs, token settings, and user-existence handling +* optional bootstrap user creation for shared, external, and training environments + +The BCSS stacks also contain stack-specific KMS and SSM parameter resources for some environments. +Those are intentionally not moved into this shared module. ## Providers @@ -96,3 +165,4 @@ No modules. + diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index 1013c189..59c5d539 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -1,59 +1,299 @@ -#################################################################################### -# BSS COMMON -#################################################################################### +variable "create" { + description = "Determines whether Cognito resources will be created." + type = bool + default = true +} + +variable "user_pool_name" { + description = "Optional explicit Cognito user pool name. Defaults to name_prefix when set, otherwise the shared context-derived module ID." + type = string + default = null +} variable "name_prefix" { - description = "The account, environment etc" + description = "Compatibility alias for older callers. Used as the default user pool and domain prefix when user_pool_name or domain are unset." + type = string + default = null +} + +variable "create_domain" { + description = "Whether to create a Cognito user pool domain." + type = bool + default = true +} + +variable "domain" { + description = "Optional Cognito user pool domain prefix or custom domain. Defaults to name_prefix or the resolved user pool name when create_domain is true." + type = string + default = null +} + +variable "domain_certificate_arn" { + description = "Optional ACM certificate ARN in us-east-1 for a custom Cognito domain." type = string + default = null +} + +variable "domain_managed_login_version" { + description = "Managed login version for the Cognito domain. Use 1 for classic hosted UI or 2 for managed login." + type = number + default = 1 + + validation { + condition = contains([1, 2], var.domain_managed_login_version) + error_message = "domain_managed_login_version must be 1 or 2." + } } -variable "environment" { - description = "The name of the Environment this is deployed into, for example CICD, NFT, UAT or PROD" +variable "user_pool_tier" { + description = "Cognito user pool tier. Valid values are LITE, ESSENTIALS, and PLUS." type = string + default = "ESSENTIALS" + + validation { + condition = contains(["LITE", "ESSENTIALS", "PLUS"], var.user_pool_tier) + error_message = "user_pool_tier must be one of LITE, ESSENTIALS, or PLUS." + } } -################################################################################## -# COGNITO -################################################################################## variable "deletion_protection" { - default = "INACTIVE" + description = "Deletion protection setting for the user pool. Valid values are ACTIVE and INACTIVE." + type = string + default = "INACTIVE" } variable "mfa_configuration" { - default = "OFF" + description = "MFA setting for the user pool. Valid values are ON, OFF, or OPTIONAL." + type = string + default = "OFF" +} + +variable "alias_attributes" { + description = "Attributes supported as aliases for the user pool. Conflicts with username_attributes in Cognito." + type = list(string) + default = null +} + +variable "username_attributes" { + description = "Attributes that can be used as usernames when users sign up. Defaults to the current bespoke behavior when left unset." + type = list(string) + default = [] +} + +variable "auto_verified_attributes" { + description = "Attributes to auto-verify in the user pool." + type = list(string) + default = ["email"] } variable "attribute_names" { - type = list(string) - default = ["acr", "amr", "email", "idassurancelevel", "nhsid_nrbac_roles", "bss_username", "sid", "uid"] + description = "Compatibility list of simple string schema attributes. Used to derive string_schemas when string_schemas is empty." + type = list(string) + default = ["acr", "amr", "email", "idassurancelevel", "nhsid_nrbac_roles", "bss_username", "sid", "uid"] +} + +variable "string_schemas" { + description = "Explicit Cognito string schema definitions. When set, these override the derived schemas from attribute_names." + type = list(any) + default = [] +} + +variable "admin_create_user_config" { + description = "AdminCreateUser configuration forwarded to the upstream module. Defaults to allow_admin_create_user_only = false to match the bespoke module behavior." + type = any + default = null +} + +variable "email_configuration" { + description = "Email configuration forwarded to the upstream module. Defaults to Cognito-managed email sending." + type = any + default = null +} + +variable "password_policy" { + description = "Password policy forwarded to the upstream module. Defaults match the current bespoke module." + type = any + default = null +} + +variable "recovery_mechanisms" { + description = "Account recovery mechanisms. Defaults to verified email first, then verified phone number." + type = list(any) + default = [] +} + +variable "username_configuration" { + description = "Username configuration forwarded to the upstream module. Defaults to case_sensitive = false." + type = any + default = null +} + +variable "verification_message_template" { + description = "Verification message template forwarded to the upstream module. Defaults to CONFIRM_WITH_CODE." + type = any + default = null +} + +variable "user_pool_add_ons_advanced_security_mode" { + description = "Advanced security mode for Cognito user pool add-ons." + type = string + default = null +} + +variable "user_pool_add_ons_advanced_security_additional_flows" { + description = "Additional advanced security configuration for custom authentication flows." + type = string + default = null +} + +variable "sign_in_policy" { + description = "Optional sign-in policy configuration." + type = any + default = null +} + +variable "software_token_mfa_configuration" { + description = "Optional software token MFA configuration." + type = any + default = {} +} + +variable "sms_configuration" { + description = "Optional SMS configuration for the user pool." + type = any + default = {} +} + +variable "email_mfa_configuration" { + description = "Optional email MFA configuration." + type = any + default = null +} + +variable "user_attribute_update_settings" { + description = "Optional user attribute update settings." + type = any + default = null +} + +variable "lambda_config" { + description = "Optional Lambda trigger configuration for Cognito." + type = any + default = {} +} + +variable "app_clients" { + description = "List of Cognito application clients to create. This wrapper intentionally supports the shared-resources OAuth client pattern rather than the full upstream clients surface." + type = list(object({ + name = optional(string) + callback_urls = list(string) + logout_urls = optional(list(string), []) + default_redirect_uri = optional(string) + generate_secret = optional(bool, true) + auth_session_validity = optional(number, 3) + allowed_oauth_flows_user_pool_client = optional(bool, true) + allowed_oauth_flows = optional(list(string), ["code"]) + allowed_oauth_scopes = optional(list(string), ["email", "openid", "profile", "aws.cognito.signin.user.admin"]) + explicit_auth_flows = optional(list(string), ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"]) + supported_identity_providers = optional(list(string), ["COGNITO"]) + enable_propagate_additional_user_context_data = optional(bool, false) + access_token_validity = optional(number) + id_token_validity = optional(number) + refresh_token_validity = optional(number) + token_validity_units = optional(map(string)) + prevent_user_existence_errors = optional(string) + read_attributes = optional(list(string), []) + write_attributes = optional(list(string), []) + enable_token_revocation = optional(bool, true) + })) + default = [] + + validation { + condition = alltrue([ + for client in var.app_clients : + try(client.default_redirect_uri, null) == null || contains(client.callback_urls, client.default_redirect_uri) + ]) + error_message = "Each app_clients.default_redirect_uri must also appear in app_clients.callback_urls." + } +} + +variable "bootstrap_users" { + description = "Optional list of bootstrap Cognito users to create. This covers the current BCSS stack pattern where initial training or shared users are provisioned during stack deployment." + type = list(object({ + uuid = string + bcss_username = string + id_assurance_level = string + rbac_role = string + user_password = optional(string) + })) + default = [] + + validation { + condition = alltrue([ + for user in var.bootstrap_users : + user.uuid != "" && user.bcss_username != "" && user.id_assurance_level != "" && user.rbac_role != "" + ]) + error_message = "Each bootstrap user must include non-empty uuid, bcss_username, id_assurance_level, and rbac_role values." + } +} + +variable "managed_login_branding_enabled" { + description = "Whether to enable Cognito managed login branding. Requires the awscc provider in the calling root module." + type = bool + default = false +} + +variable "managed_login_branding" { + description = "Managed login branding definitions forwarded to the upstream module when enabled." + type = any + default = {} +} + +variable "ignore_schema_changes" { + description = "Whether to enable the upstream schema ignore-changes workaround. Recommended for new deployments using custom schemas." + type = bool + default = true } variable "message_action" { - default = "SUPPRESS" + description = "Message action for bootstrap Cognito user creation. Defaults to SUPPRESS to match the current BCSS stacks." + type = string + default = "SUPPRESS" } variable "acr" { - default = "AAL1_USERPASS" + description = "ACR attribute applied to bootstrap Cognito users." + type = string + default = "AAL1_USERPASS" } variable "amr" { - default = "USERPASS" + description = "AMR attribute applied to bootstrap Cognito users." + type = string + default = "USERPASS" } variable "user_email" { - default = "nhsdigital.axe@nhs.net" + description = "Email attribute applied to bootstrap Cognito users." + type = string + default = "nhsdigital.axe@nhs.net" } variable "user_password" { - default = "changeme" + description = "Fallback password for bootstrap Cognito users when an individual bootstrap_users entry does not provide user_password." + type = string + default = "changeme" + sensitive = true } variable "recovery_window" { - description = "The number of days that credentials should be retained for" + description = "Deprecated compatibility input from the bespoke BS-Select bootstrap-user secret flow. No longer used by this wrapper." type = number + default = null } variable "secret_replication_regions" { - description = "List of additional regions where created secrets should be replicated" + description = "Deprecated compatibility input from the bespoke BS-Select bootstrap-user secret flow. No longer used by this wrapper." type = list(string) + default = [] } diff --git a/infrastructure/modules/cognito/versions.tf b/infrastructure/modules/cognito/versions.tf new file mode 100644 index 00000000..d671f633 --- /dev/null +++ b/infrastructure/modules/cognito/versions.tf @@ -0,0 +1,15 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" + } + + awscc = { + source = "hashicorp/awscc" + version = ">= 1.0" + } + } +} From e19de1f506281878a3967940b77f8d2cbae78424 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Fri, 5 Jun 2026 13:10:22 +0100 Subject: [PATCH 002/118] check-in for failed checks --- infrastructure/modules/cognito/readme.md | 94 +++++++++++------------- 1 file changed, 44 insertions(+), 50 deletions(-) diff --git a/infrastructure/modules/cognito/readme.md b/infrastructure/modules/cognito/readme.md index 9096e84f..679ed79c 100644 --- a/infrastructure/modules/cognito/readme.md +++ b/infrastructure/modules/cognito/readme.md @@ -1,13 +1,5 @@ # Cognito -## Summary - -This is a OAuth2 client that allows us to log into the BS-Select application using the -same controls and security that CIS2 offers. We have the ability to control the configuration -of the client, including the users available for logging in. - -# Cognito - Thin wrapper around [`lgallard/cognito-user-pool/aws`](https://registry.terraform.io/modules/lgallard/cognito-user-pool/aws/4.0.2) for the shared-resources stack. @@ -23,62 +15,62 @@ Secrets Manager password flow. * Uses the upstream `lgallard/cognito-user-pool/aws` module pinned to `4.0.2` * Derives the user pool name from `user_pool_name`, then `name_prefix`, then the - shared context-derived module ID + shared context-derived module ID * Creates a Cognito domain by default, following the prior module behaviour * Enables `ignore_schema_changes = true` by default because this is recommended - for new Cognito deployments with custom schemas + for new Cognito deployments with custom schemas * Keeps a small compatibility layer for legacy inputs such as `name_prefix` and - `attribute_names` + `attribute_names` * Narrows application client configuration to an `app_clients` interface instead - of exposing the upstream generic `clients`, `resource_servers`, `user_groups`, - and `identity_providers` inputs directly + of exposing the upstream generic `clients`, `resource_servers`, `user_groups`, + and `identity_providers` inputs directly * Supports optional bootstrap user creation because the current BCSS Cognito - stacks still provision initial users during stack deployment + stacks still provision initial users during stack deployment ## What this module does not do * It does not create or replicate a Secrets Manager password secret * It does not create the KMS keys or SSM parameters used by the older external - and training stacks; those remain stack-level concerns and can consume this - module's outputs instead + and training stacks; those remain stack-level concerns and can consume this + module's outputs instead ## Usage ```hcl module "cognito" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cognito?ref=main" - - name = "cognito" - project = "shared" - environment = "dev" - - app_clients = [ - { - callback_urls = ["https://example.internal/login/oauth2/code/nhs-identity"] - logout_urls = ["https://example.internal/logout"] - default_redirect_uri = "https://example.internal/login/oauth2/code/nhs-identity" - - allowed_oauth_flows_user_pool_client = true - allowed_oauth_flows = ["code"] - allowed_oauth_scopes = [ - "email", - "openid", - "profile", - "aws.cognito.signin.user.admin", - ] - supported_identity_providers = ["COGNITO"] - generate_secret = true - } - ] - - bootstrap_users = [ - { - uuid = "11111111-1111-1111-1111-111111111111" - bcss_username = "test.user" - id_assurance_level = "3" - rbac_role = "[{activities=[BS-Select], activity_codes=[B1808]}]" - } - ] + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cognito?ref=main" + + name = "cognito" + project = "shared" + environment = "dev" + + app_clients = [ + { + callback_urls = ["https://example.internal/login/oauth2/code/nhs-identity"] + logout_urls = ["https://example.internal/logout"] + default_redirect_uri = "https://example.internal/login/oauth2/code/nhs-identity" + + allowed_oauth_flows_user_pool_client = true + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = [ + "email", + "openid", + "profile", + "aws.cognito.signin.user.admin", + ] + supported_identity_providers = ["COGNITO"] + generate_secret = true + } + ] + + bootstrap_users = [ + { + uuid = "11111111-1111-1111-1111-111111111111" + bcss_username = "test.user" + id_assurance_level = "3" + rbac_role = "[{activities=[BS-Select], activity_codes=[B1808]}]" + } + ] } ``` @@ -115,6 +107,9 @@ Comparing against the current BCSS stacks showed that the module surface needs t The BCSS stacks also contain stack-specific KMS and SSM parameter resources for some environments. Those are intentionally not moved into this shared module. + + + ## Providers | Name | Version | @@ -165,4 +160,3 @@ No modules. - From 765ffb27725e24798ac52224fda1055d218ee104 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Mon, 8 Jun 2026 12:08:07 +0100 Subject: [PATCH 003/118] Code clean-up --- infrastructure/modules/cognito/context.tf | 282 ++------------------ infrastructure/modules/cognito/main.tf | 132 +++++---- infrastructure/modules/cognito/outputs.tf | 8 +- infrastructure/modules/cognito/variables.tf | 190 +------------ infrastructure/modules/cognito/versions.tf | 6 +- 5 files changed, 122 insertions(+), 496 deletions(-) diff --git a/infrastructure/modules/cognito/context.tf b/infrastructure/modules/cognito/context.tf index 5d47db35..b2590ce7 100644 --- a/infrastructure/modules/cognito/context.tf +++ b/infrastructure/modules/cognito/context.tf @@ -23,35 +23,35 @@ module "this" { source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.6.0" - 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 + enabled = coalesce(var.enabled, lookup(var.context, "enabled", true)) + service = coalesce(var.service, lookup(var.context, "service", null)) + project = coalesce(var.project, lookup(var.context, "project", null)) + region = lookup(var.context, "region", null) + environment = coalesce(var.environment, lookup(var.context, "environment", null)) + stack = lookup(var.context, "stack", null) + workspace = lookup(var.context, "workspace", null) + name = coalesce(var.name, lookup(var.context, "name", null)) + delimiter = lookup(var.context, "delimiter", null) + attributes = lookup(var.context, "attributes", []) + tags = merge(lookup(var.context, "tags", {}), var.tags) + additional_tag_map = lookup(var.context, "additional_tag_map", {}) + label_order = lookup(var.context, "label_order", []) + regex_replace_chars = lookup(var.context, "regex_replace_chars", null) + id_length_limit = lookup(var.context, "id_length_limit", null) + label_key_case = lookup(var.context, "label_key_case", null) + label_value_case = lookup(var.context, "label_value_case", null) + terraform_source = coalesce(var.terraform_source, lookup(var.context, "terraform_source", null), path.module) + descriptor_formats = lookup(var.context, "descriptor_formats", {}) + labels_as_tags = toset(lookup(var.context, "labels_as_tags", ["unset"])) 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" + description = "AWS region used for derived Cognito hosted UI outputs." 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" @@ -110,261 +110,35 @@ variable "terraform_source" { variable "enabled" { type = bool default = null - description = "Set to false to prevent the module from creating any resources" + 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" + description = "Service identifier used by the shared tags module." } 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" + description = "Project identifier used by the shared tags module." } variable "environment" { type = string default = null - description = "ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat'" + description = "Environment identifier used by the shared tags module." } 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 + description = "Name identifier used by the shared tags module." } 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 + description = "Additional tags merged with any tags supplied through the context object." } - -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 -} - -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 diff --git a/infrastructure/modules/cognito/main.tf b/infrastructure/modules/cognito/main.tf index 119351f0..c256ad07 100644 --- a/infrastructure/modules/cognito/main.tf +++ b/infrastructure/modules/cognito/main.tf @@ -1,7 +1,15 @@ +data "aws_ssm_parameter" "bootstrap_users" { + count = module.this.enabled && var.create && var.bootstrap_users_ssm_parameter_name != null ? 1 : 0 + + name = var.bootstrap_users_ssm_parameter_name +} + locals { - user_pool_name = coalesce(var.user_pool_name, var.name_prefix != null ? "${var.name_prefix}-users-pool" : null, module.this.id) - domain_name = var.create_domain ? coalesce(var.domain, var.name_prefix, local.user_pool_name) : null + user_pool_name = coalesce(var.name_prefix != null ? "${var.name_prefix}-users-pool" : null, module.this.id) + domain_name = coalesce(var.domain, var.name_prefix, local.user_pool_name) default_app_client_name = coalesce(var.name_prefix != null ? "${var.name_prefix}-users-client" : null, "${local.user_pool_name}-client") + ssm_bootstrap_users = var.bootstrap_users_ssm_parameter_name != null ? try(jsondecode(nonsensitive(data.aws_ssm_parameter.bootstrap_users[0].value)), []) : [] + resolved_bootstrap_users = length(var.bootstrap_users) > 0 ? var.bootstrap_users : local.ssm_bootstrap_users default_admin_create_user_config = { allow_admin_create_user_only = false @@ -31,6 +39,14 @@ locals { } ] + default_username_configuration = { + case_sensitive = false + } + + default_verification_message_template = { + default_email_option = "CONFIRM_WITH_CODE" + } + default_string_schemas = [ for attribute_name in var.attribute_names : { attribute_data_type = "String" @@ -45,88 +61,92 @@ locals { } ] - resolved_admin_create_user_config = var.admin_create_user_config != null ? var.admin_create_user_config : local.default_admin_create_user_config - resolved_email_configuration = var.email_configuration != null ? var.email_configuration : local.default_email_configuration - resolved_password_policy = var.password_policy != null ? var.password_policy : local.default_password_policy - resolved_recovery_mechanisms = length(var.recovery_mechanisms) > 0 ? var.recovery_mechanisms : local.default_recovery_mechanisms - resolved_string_schemas = length(var.string_schemas) > 0 ? var.string_schemas : local.default_string_schemas - resolved_username_configuration = var.username_configuration != null ? var.username_configuration : { case_sensitive = false } - resolved_verification_message_template = var.verification_message_template != null ? var.verification_message_template : { default_email_option = "CONFIRM_WITH_CODE" } - cognito_clients = [ for client in var.app_clients : { - name = client.name + name = try(client.name, local.default_app_client_name) callback_urls = client.callback_urls logout_urls = client.logout_urls default_redirect_uri = try(client.default_redirect_uri, null) generate_secret = try(client.generate_secret, true) auth_session_validity = try(client.auth_session_validity, 3) - allowed_oauth_flows_user_pool_client = try(client.allowed_oauth_flows_user_pool_client, true) - allowed_oauth_flows = try(client.allowed_oauth_flows, ["code"]) - allowed_oauth_scopes = try(client.allowed_oauth_scopes, ["email", "openid", "profile", "aws.cognito.signin.user.admin"]) - explicit_auth_flows = try(client.explicit_auth_flows, ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"]) - supported_identity_providers = try(client.supported_identity_providers, ["COGNITO"]) + allowed_oauth_flows_user_pool_client = true + allowed_oauth_flows = ["code"] + allowed_oauth_scopes = ["email", "openid", "profile", "aws.cognito.signin.user.admin"] + explicit_auth_flows = ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"] + supported_identity_providers = ["COGNITO"] enable_propagate_additional_user_context_data = try(client.enable_propagate_additional_user_context_data, false) - access_token_validity = try(client.access_token_validity, null) + access_token_validity = 60 id_token_validity = try(client.id_token_validity, null) refresh_token_validity = try(client.refresh_token_validity, null) - token_validity_units = try(client.token_validity_units, { access_token = "minutes", id_token = "minutes", refresh_token = "days" }) + token_validity_units = { access_token = "minutes", id_token = "minutes", refresh_token = "days" } prevent_user_existence_errors = try(client.prevent_user_existence_errors, null) - read_attributes = try(client.read_attributes, []) - write_attributes = try(client.write_attributes, []) enable_token_revocation = try(client.enable_token_revocation, true) - name = try(client.name, local.default_app_client_name) } ] } +resource "random_password" "bootstrap_user_password" { + count = module.this.enabled && var.create && length(local.resolved_bootstrap_users) > 0 ? 1 : 0 + + length = 20 + special = true + override_special = "!%^*-_+=" +} + +resource "aws_secretsmanager_secret" "bootstrap_user_password" { + count = module.this.enabled && var.create && length(local.resolved_bootstrap_users) > 0 ? 1 : 0 + + name = "${local.user_pool_name}-cognito-user" + recovery_window_in_days = var.recovery_window + + dynamic "replica" { + for_each = var.secret_replication_regions + content { + region = replica.value + } + } +} + +resource "aws_secretsmanager_secret_version" "bootstrap_user_password" { + count = module.this.enabled && var.create && length(local.resolved_bootstrap_users) > 0 ? 1 : 0 + + secret_id = aws_secretsmanager_secret.bootstrap_user_password[0].id + secret_string = jsonencode({ + password = coalesce(var.user_password, random_password.bootstrap_user_password[0].result) + }) +} + module "cognito" { source = "lgallard/cognito-user-pool/aws" version = "4.0.2" enabled = module.this.enabled && var.create - user_pool_name = local.user_pool_name - user_pool_tier = var.user_pool_tier - domain = local.domain_name - domain_certificate_arn = var.domain_certificate_arn - domain_managed_login_version = var.domain_managed_login_version - deletion_protection = var.deletion_protection - alias_attributes = var.alias_attributes - username_attributes = var.username_attributes - auto_verified_attributes = var.auto_verified_attributes - mfa_configuration = var.mfa_configuration - - admin_create_user_config = local.resolved_admin_create_user_config - email_configuration = local.resolved_email_configuration - password_policy = local.resolved_password_policy - recovery_mechanisms = local.resolved_recovery_mechanisms - string_schemas = local.resolved_string_schemas - username_configuration = local.resolved_username_configuration - verification_message_template = local.resolved_verification_message_template - - user_pool_add_ons_advanced_security_mode = var.user_pool_add_ons_advanced_security_mode - user_pool_add_ons_advanced_security_additional_flows = var.user_pool_add_ons_advanced_security_additional_flows - sign_in_policy = var.sign_in_policy - software_token_mfa_configuration = var.software_token_mfa_configuration - sms_configuration = var.sms_configuration - email_mfa_configuration = var.email_mfa_configuration - user_attribute_update_settings = var.user_attribute_update_settings - lambda_config = var.lambda_config - clients = local.cognito_clients - managed_login_branding_enabled = var.managed_login_branding_enabled - managed_login_branding = var.managed_login_branding - ignore_schema_changes = var.ignore_schema_changes + user_pool_name = local.user_pool_name + domain = local.domain_name + deletion_protection = var.deletion_protection + auto_verified_attributes = ["email"] + mfa_configuration = var.mfa_configuration + + admin_create_user_config = local.default_admin_create_user_config + email_configuration = local.default_email_configuration + password_policy = local.default_password_policy + recovery_mechanisms = local.default_recovery_mechanisms + string_schemas = local.default_string_schemas + username_configuration = local.default_username_configuration + verification_message_template = local.default_verification_message_template + clients = local.cognito_clients + ignore_schema_changes = true tags = module.this.tags } resource "aws_cognito_user" "bootstrap_users" { - for_each = module.this.enabled && var.create ? { for user in var.bootstrap_users : user.uuid => user } : {} + for_each = module.this.enabled && var.create ? { for user in local.resolved_bootstrap_users : user.uuid => user } : {} user_pool_id = module.cognito.id - username = each.value.bcss_username - password = coalesce(try(each.value.user_password, null), var.user_password) + username = coalesce(try(each.value.bss_username, null), try(each.value.bcss_username, null)) + password = coalesce(try(each.value.user_password, null), var.user_password, random_password.bootstrap_user_password[0].result) message_action = var.message_action attributes = { @@ -136,7 +156,7 @@ resource "aws_cognito_user" "bootstrap_users" { email_verified = true idassurancelevel = each.value.id_assurance_level nhsid_nrbac_roles = each.value.rbac_role - bcss_username = each.value.bcss_username + bss_username = coalesce(try(each.value.bss_username, null), try(each.value.bcss_username, null)) sid = each.value.uuid uid = each.value.uuid } diff --git a/infrastructure/modules/cognito/outputs.tf b/infrastructure/modules/cognito/outputs.tf index 97f5e86a..0c66ea45 100644 --- a/infrastructure/modules/cognito/outputs.tf +++ b/infrastructure/modules/cognito/outputs.tf @@ -19,13 +19,13 @@ output "user_pool_endpoint" { } output "user_pool_domain_prefix" { - description = "Configured Cognito domain value when create_domain is enabled." + description = "Configured Cognito domain value." value = local.domain_name } output "user_pool_hosted_ui_url" { description = "Hosted UI URL for the Cognito domain when a default domain prefix is configured." - value = local.domain_name != null && var.domain_certificate_arn == null ? "https://${local.domain_name}.auth.${var.aws_region}.amazoncognito.com" : null + value = local.domain_name != null ? "https://${local.domain_name}.auth.${var.aws_region}.amazoncognito.com" : null } output "client_ids" { @@ -62,6 +62,6 @@ output "app_client_secrets" { } output "secrets_manager_random_passsword_arn" { - description = "Deprecated compatibility output from the bespoke BS-Select bootstrap-user flow. This wrapper does not create a bootstrap user secret." - value = null + description = "ARN of the bootstrap-user secret when the module creates one for compatibility with the old BS-Select flow." + value = try(aws_secretsmanager_secret.bootstrap_user_password[0].arn, null) } diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index 59c5d539..467818e0 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -4,58 +4,18 @@ variable "create" { default = true } -variable "user_pool_name" { - description = "Optional explicit Cognito user pool name. Defaults to name_prefix when set, otherwise the shared context-derived module ID." - type = string - default = null -} - variable "name_prefix" { description = "Compatibility alias for older callers. Used as the default user pool and domain prefix when user_pool_name or domain are unset." type = string default = null } -variable "create_domain" { - description = "Whether to create a Cognito user pool domain." - type = bool - default = true -} - variable "domain" { - description = "Optional Cognito user pool domain prefix or custom domain. Defaults to name_prefix or the resolved user pool name when create_domain is true." - type = string - default = null -} - -variable "domain_certificate_arn" { - description = "Optional ACM certificate ARN in us-east-1 for a custom Cognito domain." + description = "Optional Cognito user pool domain prefix. Defaults to name_prefix or the resolved user pool name." type = string default = null } -variable "domain_managed_login_version" { - description = "Managed login version for the Cognito domain. Use 1 for classic hosted UI or 2 for managed login." - type = number - default = 1 - - validation { - condition = contains([1, 2], var.domain_managed_login_version) - error_message = "domain_managed_login_version must be 1 or 2." - } -} - -variable "user_pool_tier" { - description = "Cognito user pool tier. Valid values are LITE, ESSENTIALS, and PLUS." - type = string - default = "ESSENTIALS" - - validation { - condition = contains(["LITE", "ESSENTIALS", "PLUS"], var.user_pool_tier) - error_message = "user_pool_tier must be one of LITE, ESSENTIALS, or PLUS." - } -} - variable "deletion_protection" { description = "Deletion protection setting for the user pool. Valid values are ACTIVE and INACTIVE." type = string @@ -68,120 +28,18 @@ variable "mfa_configuration" { default = "OFF" } -variable "alias_attributes" { - description = "Attributes supported as aliases for the user pool. Conflicts with username_attributes in Cognito." - type = list(string) - default = null -} - -variable "username_attributes" { - description = "Attributes that can be used as usernames when users sign up. Defaults to the current bespoke behavior when left unset." - type = list(string) - default = [] -} - -variable "auto_verified_attributes" { - description = "Attributes to auto-verify in the user pool." - type = list(string) - default = ["email"] -} - variable "attribute_names" { description = "Compatibility list of simple string schema attributes. Used to derive string_schemas when string_schemas is empty." type = list(string) default = ["acr", "amr", "email", "idassurancelevel", "nhsid_nrbac_roles", "bss_username", "sid", "uid"] } -variable "string_schemas" { - description = "Explicit Cognito string schema definitions. When set, these override the derived schemas from attribute_names." - type = list(any) - default = [] -} - -variable "admin_create_user_config" { - description = "AdminCreateUser configuration forwarded to the upstream module. Defaults to allow_admin_create_user_only = false to match the bespoke module behavior." - type = any - default = null -} - -variable "email_configuration" { - description = "Email configuration forwarded to the upstream module. Defaults to Cognito-managed email sending." - type = any - default = null -} - -variable "password_policy" { - description = "Password policy forwarded to the upstream module. Defaults match the current bespoke module." - type = any - default = null -} - -variable "recovery_mechanisms" { - description = "Account recovery mechanisms. Defaults to verified email first, then verified phone number." - type = list(any) - default = [] -} - -variable "username_configuration" { - description = "Username configuration forwarded to the upstream module. Defaults to case_sensitive = false." - type = any - default = null -} - -variable "verification_message_template" { - description = "Verification message template forwarded to the upstream module. Defaults to CONFIRM_WITH_CODE." - type = any - default = null -} - -variable "user_pool_add_ons_advanced_security_mode" { - description = "Advanced security mode for Cognito user pool add-ons." - type = string - default = null -} - -variable "user_pool_add_ons_advanced_security_additional_flows" { - description = "Additional advanced security configuration for custom authentication flows." +variable "bootstrap_users_ssm_parameter_name" { + description = "Optional SSM parameter name containing a JSON array of bootstrap users. This preserves the old BS-Select bootstrap-user source without forcing BCSS to use it." type = string default = null } -variable "sign_in_policy" { - description = "Optional sign-in policy configuration." - type = any - default = null -} - -variable "software_token_mfa_configuration" { - description = "Optional software token MFA configuration." - type = any - default = {} -} - -variable "sms_configuration" { - description = "Optional SMS configuration for the user pool." - type = any - default = {} -} - -variable "email_mfa_configuration" { - description = "Optional email MFA configuration." - type = any - default = null -} - -variable "user_attribute_update_settings" { - description = "Optional user attribute update settings." - type = any - default = null -} - -variable "lambda_config" { - description = "Optional Lambda trigger configuration for Cognito." - type = any - default = {} -} - variable "app_clients" { description = "List of Cognito application clients to create. This wrapper intentionally supports the shared-resources OAuth client pattern rather than the full upstream clients surface." type = list(object({ @@ -191,19 +49,10 @@ variable "app_clients" { default_redirect_uri = optional(string) generate_secret = optional(bool, true) auth_session_validity = optional(number, 3) - allowed_oauth_flows_user_pool_client = optional(bool, true) - allowed_oauth_flows = optional(list(string), ["code"]) - allowed_oauth_scopes = optional(list(string), ["email", "openid", "profile", "aws.cognito.signin.user.admin"]) - explicit_auth_flows = optional(list(string), ["ALLOW_REFRESH_TOKEN_AUTH", "ALLOW_USER_SRP_AUTH", "ALLOW_USER_PASSWORD_AUTH"]) - supported_identity_providers = optional(list(string), ["COGNITO"]) enable_propagate_additional_user_context_data = optional(bool, false) - access_token_validity = optional(number) id_token_validity = optional(number) refresh_token_validity = optional(number) - token_validity_units = optional(map(string)) prevent_user_existence_errors = optional(string) - read_attributes = optional(list(string), []) - write_attributes = optional(list(string), []) enable_token_revocation = optional(bool, true) })) default = [] @@ -221,7 +70,8 @@ variable "bootstrap_users" { description = "Optional list of bootstrap Cognito users to create. This covers the current BCSS stack pattern where initial training or shared users are provisioned during stack deployment." type = list(object({ uuid = string - bcss_username = string + bss_username = optional(string) + bcss_username = optional(string) id_assurance_level = string rbac_role = string user_password = optional(string) @@ -231,30 +81,12 @@ variable "bootstrap_users" { validation { condition = alltrue([ for user in var.bootstrap_users : - user.uuid != "" && user.bcss_username != "" && user.id_assurance_level != "" && user.rbac_role != "" + user.uuid != "" && coalesce(try(user.bss_username, null), try(user.bcss_username, null)) != null && coalesce(try(user.bss_username, null), try(user.bcss_username, null)) != "" && user.id_assurance_level != "" && user.rbac_role != "" ]) - error_message = "Each bootstrap user must include non-empty uuid, bcss_username, id_assurance_level, and rbac_role values." + error_message = "Each bootstrap user must include non-empty uuid, either bss_username or bcss_username, id_assurance_level, and rbac_role values." } } -variable "managed_login_branding_enabled" { - description = "Whether to enable Cognito managed login branding. Requires the awscc provider in the calling root module." - type = bool - default = false -} - -variable "managed_login_branding" { - description = "Managed login branding definitions forwarded to the upstream module when enabled." - type = any - default = {} -} - -variable "ignore_schema_changes" { - description = "Whether to enable the upstream schema ignore-changes workaround. Recommended for new deployments using custom schemas." - type = bool - default = true -} - variable "message_action" { description = "Message action for bootstrap Cognito user creation. Defaults to SUPPRESS to match the current BCSS stacks." type = string @@ -282,18 +114,18 @@ variable "user_email" { variable "user_password" { description = "Fallback password for bootstrap Cognito users when an individual bootstrap_users entry does not provide user_password." type = string - default = "changeme" + default = null sensitive = true } variable "recovery_window" { - description = "Deprecated compatibility input from the bespoke BS-Select bootstrap-user secret flow. No longer used by this wrapper." + description = "Recovery window in days for the optional bootstrap-user secret used for BS-Select compatibility." type = number - default = null + default = 0 } variable "secret_replication_regions" { - description = "Deprecated compatibility input from the bespoke BS-Select bootstrap-user secret flow. No longer used by this wrapper." + description = "Optional additional AWS regions where the bootstrap-user secret should be replicated." type = list(string) default = [] } diff --git a/infrastructure/modules/cognito/versions.tf b/infrastructure/modules/cognito/versions.tf index d671f633..e5d52592 100644 --- a/infrastructure/modules/cognito/versions.tf +++ b/infrastructure/modules/cognito/versions.tf @@ -7,9 +7,9 @@ terraform { version = ">= 6.42" } - awscc = { - source = "hashicorp/awscc" - version = ">= 1.0" + random = { + source = "hashicorp/random" + version = ">= 3.0" } } } From e745da15d84778e7553505e27ac5acf44acf799d Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Mon, 8 Jun 2026 12:14:26 +0100 Subject: [PATCH 004/118] code clean-up --- infrastructure/modules/cognito/main.tf | 47 ++------------------- infrastructure/modules/cognito/outputs.tf | 4 +- infrastructure/modules/cognito/variables.tf | 29 +++---------- infrastructure/modules/cognito/versions.tf | 5 --- 4 files changed, 11 insertions(+), 74 deletions(-) diff --git a/infrastructure/modules/cognito/main.tf b/infrastructure/modules/cognito/main.tf index c256ad07..a4c4a1ea 100644 --- a/infrastructure/modules/cognito/main.tf +++ b/infrastructure/modules/cognito/main.tf @@ -1,15 +1,7 @@ -data "aws_ssm_parameter" "bootstrap_users" { - count = module.this.enabled && var.create && var.bootstrap_users_ssm_parameter_name != null ? 1 : 0 - - name = var.bootstrap_users_ssm_parameter_name -} - locals { user_pool_name = coalesce(var.name_prefix != null ? "${var.name_prefix}-users-pool" : null, module.this.id) domain_name = coalesce(var.domain, var.name_prefix, local.user_pool_name) default_app_client_name = coalesce(var.name_prefix != null ? "${var.name_prefix}-users-client" : null, "${local.user_pool_name}-client") - ssm_bootstrap_users = var.bootstrap_users_ssm_parameter_name != null ? try(jsondecode(nonsensitive(data.aws_ssm_parameter.bootstrap_users[0].value)), []) : [] - resolved_bootstrap_users = length(var.bootstrap_users) > 0 ? var.bootstrap_users : local.ssm_bootstrap_users default_admin_create_user_config = { allow_admin_create_user_only = false @@ -85,37 +77,6 @@ locals { ] } -resource "random_password" "bootstrap_user_password" { - count = module.this.enabled && var.create && length(local.resolved_bootstrap_users) > 0 ? 1 : 0 - - length = 20 - special = true - override_special = "!%^*-_+=" -} - -resource "aws_secretsmanager_secret" "bootstrap_user_password" { - count = module.this.enabled && var.create && length(local.resolved_bootstrap_users) > 0 ? 1 : 0 - - name = "${local.user_pool_name}-cognito-user" - recovery_window_in_days = var.recovery_window - - dynamic "replica" { - for_each = var.secret_replication_regions - content { - region = replica.value - } - } -} - -resource "aws_secretsmanager_secret_version" "bootstrap_user_password" { - count = module.this.enabled && var.create && length(local.resolved_bootstrap_users) > 0 ? 1 : 0 - - secret_id = aws_secretsmanager_secret.bootstrap_user_password[0].id - secret_string = jsonencode({ - password = coalesce(var.user_password, random_password.bootstrap_user_password[0].result) - }) -} - module "cognito" { source = "lgallard/cognito-user-pool/aws" version = "4.0.2" @@ -142,11 +103,11 @@ module "cognito" { } resource "aws_cognito_user" "bootstrap_users" { - for_each = module.this.enabled && var.create ? { for user in local.resolved_bootstrap_users : user.uuid => user } : {} + for_each = module.this.enabled && var.create ? { for user in var.bootstrap_users : user.uuid => user } : {} user_pool_id = module.cognito.id - username = coalesce(try(each.value.bss_username, null), try(each.value.bcss_username, null)) - password = coalesce(try(each.value.user_password, null), var.user_password, random_password.bootstrap_user_password[0].result) + username = each.value.bcss_username + password = coalesce(try(each.value.user_password, null), var.user_password) message_action = var.message_action attributes = { @@ -156,7 +117,7 @@ resource "aws_cognito_user" "bootstrap_users" { email_verified = true idassurancelevel = each.value.id_assurance_level nhsid_nrbac_roles = each.value.rbac_role - bss_username = coalesce(try(each.value.bss_username, null), try(each.value.bcss_username, null)) + bcss_username = each.value.bcss_username sid = each.value.uuid uid = each.value.uuid } diff --git a/infrastructure/modules/cognito/outputs.tf b/infrastructure/modules/cognito/outputs.tf index 0c66ea45..3cb1cfb9 100644 --- a/infrastructure/modules/cognito/outputs.tf +++ b/infrastructure/modules/cognito/outputs.tf @@ -62,6 +62,6 @@ output "app_client_secrets" { } output "secrets_manager_random_passsword_arn" { - description = "ARN of the bootstrap-user secret when the module creates one for compatibility with the old BS-Select flow." - value = try(aws_secretsmanager_secret.bootstrap_user_password[0].arn, null) + description = "Deprecated compatibility output from the bespoke BS-Select bootstrap-user flow. This wrapper does not create a bootstrap user secret." + value = null } diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index 467818e0..945b6bc2 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -31,13 +31,7 @@ variable "mfa_configuration" { variable "attribute_names" { description = "Compatibility list of simple string schema attributes. Used to derive string_schemas when string_schemas is empty." type = list(string) - default = ["acr", "amr", "email", "idassurancelevel", "nhsid_nrbac_roles", "bss_username", "sid", "uid"] -} - -variable "bootstrap_users_ssm_parameter_name" { - description = "Optional SSM parameter name containing a JSON array of bootstrap users. This preserves the old BS-Select bootstrap-user source without forcing BCSS to use it." - type = string - default = null + default = ["acr", "amr", "email", "idassurancelevel", "nhsid_nrbac_roles", "bcss_username", "sid", "uid"] } variable "app_clients" { @@ -70,8 +64,7 @@ variable "bootstrap_users" { description = "Optional list of bootstrap Cognito users to create. This covers the current BCSS stack pattern where initial training or shared users are provisioned during stack deployment." type = list(object({ uuid = string - bss_username = optional(string) - bcss_username = optional(string) + bcss_username = string id_assurance_level = string rbac_role = string user_password = optional(string) @@ -81,9 +74,9 @@ variable "bootstrap_users" { validation { condition = alltrue([ for user in var.bootstrap_users : - user.uuid != "" && coalesce(try(user.bss_username, null), try(user.bcss_username, null)) != null && coalesce(try(user.bss_username, null), try(user.bcss_username, null)) != "" && user.id_assurance_level != "" && user.rbac_role != "" + user.uuid != "" && user.bcss_username != "" && user.id_assurance_level != "" && user.rbac_role != "" ]) - error_message = "Each bootstrap user must include non-empty uuid, either bss_username or bcss_username, id_assurance_level, and rbac_role values." + error_message = "Each bootstrap user must include non-empty uuid, bcss_username, id_assurance_level, and rbac_role values." } } @@ -114,18 +107,6 @@ variable "user_email" { variable "user_password" { description = "Fallback password for bootstrap Cognito users when an individual bootstrap_users entry does not provide user_password." type = string - default = null + default = "changeme" sensitive = true } - -variable "recovery_window" { - description = "Recovery window in days for the optional bootstrap-user secret used for BS-Select compatibility." - type = number - default = 0 -} - -variable "secret_replication_regions" { - description = "Optional additional AWS regions where the bootstrap-user secret should be replicated." - type = list(string) - default = [] -} diff --git a/infrastructure/modules/cognito/versions.tf b/infrastructure/modules/cognito/versions.tf index e5d52592..cb30fe5c 100644 --- a/infrastructure/modules/cognito/versions.tf +++ b/infrastructure/modules/cognito/versions.tf @@ -6,10 +6,5 @@ terraform { source = "hashicorp/aws" version = ">= 6.42" } - - random = { - source = "hashicorp/random" - version = ">= 3.0" - } } } From a1acb616aac8ed21ad8aa3274ed980193bfee148 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 10 Jun 2026 12:35:37 +0100 Subject: [PATCH 005/118] Created initial ALB/NLB module --- infrastructure/modules/alb/README.md | 197 +++++++++++++ infrastructure/modules/alb/context.tf | 374 ++++++++++++++++++++++++ infrastructure/modules/alb/main.tf | 68 +++++ infrastructure/modules/alb/outputs.tf | 44 +++ infrastructure/modules/alb/variables.tf | 110 +++++++ infrastructure/modules/alb/versions.tf | 10 + 6 files changed, 803 insertions(+) create mode 100644 infrastructure/modules/alb/README.md create mode 100644 infrastructure/modules/alb/context.tf create mode 100644 infrastructure/modules/alb/main.tf create mode 100644 infrastructure/modules/alb/outputs.tf create mode 100644 infrastructure/modules/alb/variables.tf create mode 100644 infrastructure/modules/alb/versions.tf diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md new file mode 100644 index 00000000..54bdbec7 --- /dev/null +++ b/infrastructure/modules/alb/README.md @@ -0,0 +1,197 @@ +# AWS Application / Network Load Balancer Terraform module + +Thin NHS wrapper around [terraform-aws-modules/alb/aws](https://registry.terraform.io/modules/terraform-aws-modules/alb/aws) that enforces the screening platform's baseline controls. + +## Fixed controls + +| Setting | Value | Reason | +|---|---|---| +| `enable_deletion_protection` | `true` | Prevents accidental load balancer deletion via the AWS API | +| `drop_invalid_header_fields` | `true` (ALB only) | Prevents HTTP header injection attacks | + +## Usage + +### Internet-facing ALB with HTTPS + +```hcl +module "alb" { + source = "../../modules/alb" + + context = module.this.context + stack = "web" + name = "bcss-web" + label_order = ["service", "environment", "stack", "name"] + + vpc_id = data.aws_vpc.selected.id + subnets = data.aws_subnets.public.ids + + access_logs = { + bucket = module.logs_bucket.id + prefix = terraform.workspace + enabled = true + } + + security_group_ingress_rules = { + http = { + from_port = 80 + to_port = 80 + ip_protocol = "tcp" + description = "HTTP from internet — redirected to HTTPS by listener" + cidr_ipv4 = "0.0.0.0/0" + } + https = { + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "HTTPS from internet" + cidr_ipv4 = "0.0.0.0/0" + } + } + + security_group_egress_rules = { + https_out = { + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "HTTPS to targets" + cidr_ipv4 = "0.0.0.0/0" + } + } + + listeners = { + http-redirect = { + port = 80 + protocol = "HTTP" + redirect = { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + https = { + port = 443 + protocol = "HTTPS" + ssl_policy = "ELBSecurityPolicy-TLS13-1-2-Res-2021-06" + certificate_arn = local.acm_certificate_arn + forward = { + target_group_key = "web" + } + } + } + + target_groups = { + web = { + port = 443 + protocol = "HTTPS" + target_type = "ip" + health_check = { + protocol = "HTTPS" + path = "/health" + matcher = "200" + healthy_threshold = 2 + unhealthy_threshold = 2 + interval = 60 + } + } + } +} +``` + +### ALB with WAF association + +```hcl +module "alb" { + source = "../../modules/alb" + + context = module.this.context + name = "bcss-web" + + vpc_id = data.aws_vpc.selected.id + subnets = data.aws_subnets.public.ids + + listeners = { ... } + target_groups = { ... } + + web_acl_arn = module.waf.web_acl_arn +} +``` + +### Internal NLB + +```hcl +module "nlb" { + source = "../../modules/alb" + + context = module.this.context + name = "internal-nlb" + + load_balancer_type = "network" + internal = true + vpc_id = data.aws_vpc.selected.id + subnets = data.aws_subnets.private.ids + + security_group_ingress_rules = { + tcp = { + from_port = 8080 + to_port = 8080 + ip_protocol = "tcp" + description = "Internal TCP traffic" + cidr_ipv4 = data.aws_vpc.selected.cidr_block + } + } + + security_group_egress_rules = { + tcp_out = { + from_port = 8080 + to_port = 8080 + ip_protocol = "tcp" + cidr_ipv4 = data.aws_vpc.selected.cidr_block + } + } + + listeners = { + tcp = { + port = 8080 + protocol = "TCP" + forward = { target_group_key = "service" } + } + } + + target_groups = { + service = { + port = 8080 + protocol = "TCP" + target_type = "ip" + health_check = { + protocol = "TCP" + port = "8080" + } + } + } +} +``` + +## Outputs + +| Name | Description | Used by | +|---|---|---| +| `arn` | ARN of the load balancer | WAF ACL association | +| `dns_name` | DNS name of the load balancer | Route53 alias records | +| `zone_id` | Hosted zone ID of the load balancer | Route53 alias records | +| `listeners` | Map of listeners and their attributes | ECS task `depends_on` | +| `target_groups` | Map of target groups and their attributes | ECS task `target_group_arn` | +| `security_group_id` | ID of the load balancer security group | ECS task security group rules | + + + + +## Requirements + +| Name | Version | +|---|---| +| terraform | >= 1.5.7 | +| aws | >= 6.28 | + + + + diff --git a/infrastructure/modules/alb/context.tf b/infrastructure/modules/alb/context.tf new file mode 100644 index 00000000..39d9b945 --- /dev/null +++ b/infrastructure/modules/alb/context.tf @@ -0,0 +1,374 @@ +# +# 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" { + source = "../tags" + + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/alb/main.tf b/infrastructure/modules/alb/main.tf new file mode 100644 index 00000000..fe03f1a0 --- /dev/null +++ b/infrastructure/modules/alb/main.tf @@ -0,0 +1,68 @@ +################################################################ +# Application / Network Load Balancer +# +# Thin NHS wrapper around the community ALB module that enforces +# the screening platform's baseline controls: +# +# * Deletion protection: always enabled +# * Invalid header fields: always dropped (ALB only) +# * Naming: derived from context labels via module.this.id +# * Tagging: all NHS-required tags applied automatically +# * Enabled flag: create = module.this.enabled +# +# Inputs intentionally NOT exposed (hardcoded below): +# - enable_deletion_protection → always true +# - drop_invalid_header_fields → always true (ALB); null (NLB) +################################################################ + +module "alb" { + source = "terraform-aws-modules/alb/aws" + version = "10.5.0" + + create = module.this.enabled + + name = module.this.id + load_balancer_type = var.load_balancer_type + internal = var.internal + vpc_id = var.vpc_id + subnets = var.subnets + + # ---------------------------------------------------------------- + # Security baseline — hardcoded, callers cannot override. + # drop_invalid_header_fields is ALB-only; pass null for NLB so + # the upstream module does not error. + # ---------------------------------------------------------------- + enable_deletion_protection = true + drop_invalid_header_fields = var.load_balancer_type == "application" ? true : null + + # ---------------------------------------------------------------- + # Security group rules — caller-supplied so both ALB (HTTP+HTTPS) + # and NLB (TCP) patterns are supported. + # ---------------------------------------------------------------- + security_group_ingress_rules = var.security_group_ingress_rules + security_group_egress_rules = var.security_group_egress_rules + + # ---------------------------------------------------------------- + # Access logging to a caller-supplied S3 bucket. + # ---------------------------------------------------------------- + access_logs = var.access_logs + + # ---------------------------------------------------------------- + # Listeners and target groups — passed through as-is. + # Callers define the full listener/target group configuration + # including SSL policies, certificates, health checks, etc. + # ---------------------------------------------------------------- + listeners = var.listeners + target_groups = var.target_groups + + # ---------------------------------------------------------------- + # WAF association — optional, ALB only. + # ---------------------------------------------------------------- + associate_web_acl = var.web_acl_arn != null + web_acl_arn = var.web_acl_arn + + # ---------------------------------------------------------------- + # Tags — automatically populated from context. + # ---------------------------------------------------------------- + tags = module.this.tags +} diff --git a/infrastructure/modules/alb/outputs.tf b/infrastructure/modules/alb/outputs.tf new file mode 100644 index 00000000..6a659a92 --- /dev/null +++ b/infrastructure/modules/alb/outputs.tf @@ -0,0 +1,44 @@ +output "arn" { + description = "ARN of the load balancer. Used by WAF to associate a Web ACL." + value = module.alb.arn +} + +output "dns_name" { + description = "DNS name of the load balancer. Used by Route53 alias records." + value = module.alb.dns_name +} + +output "zone_id" { + description = "Hosted zone ID of the load balancer. Used by Route53 alias records." + value = module.alb.zone_id +} + +output "id" { + description = "ID of the load balancer (same as ARN)." + value = module.alb.id +} + +output "arn_suffix" { + description = "ARN suffix of the load balancer. Used with CloudWatch metrics." + value = module.alb.arn_suffix +} + +output "listeners" { + description = "Map of listeners created and their attributes. ECS tasks use this for depends_on." + value = module.alb.listeners +} + +output "target_groups" { + description = "Map of target groups created and their attributes. ECS tasks reference target_group ARNs from here." + value = module.alb.target_groups +} + +output "security_group_id" { + description = "ID of the security group created for the load balancer." + value = module.alb.security_group_id +} + +output "security_group_arn" { + description = "ARN of the security group created for the load balancer." + value = module.alb.security_group_arn +} diff --git a/infrastructure/modules/alb/variables.tf b/infrastructure/modules/alb/variables.tf new file mode 100644 index 00000000..e1bbcaf3 --- /dev/null +++ b/infrastructure/modules/alb/variables.tf @@ -0,0 +1,110 @@ +################################################################ +# ALB/NLB-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# context.tf via `module.this`. +# +# Inputs intentionally NOT exposed (hardcoded in main.tf): +# - enable_deletion_protection → always true +# - drop_invalid_header_fields → always true for ALB +################################################################ + +variable "load_balancer_type" { + type = string + default = "application" + description = "Type of load balancer to create. Either 'application' (ALB) or 'network' (NLB)." + + validation { + condition = contains(["application", "network"], var.load_balancer_type) + error_message = "load_balancer_type must be 'application' or 'network'." + } +} + +variable "internal" { + type = bool + default = false + description = "When true, the load balancer is internal (private). When false, it is internet-facing. Defaults to false." +} + +variable "subnets" { + type = list(string) + description = "List of subnet IDs to attach to the load balancer. For internet-facing ALBs, use public subnets." +} + +variable "vpc_id" { + type = string + description = "ID of the VPC in which the load balancer security group will be created." +} + +variable "security_group_ingress_rules" { + type = any + default = {} + description = <<-EOT + Map of ingress rules to add to the load balancer security group. + Each key is a logical name; each value is an object describing the rule. + Example: + security_group_ingress_rules = { + https = { + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + description = "HTTPS from internet" + cidr_ipv4 = "0.0.0.0/0" + } + } + EOT +} + +variable "security_group_egress_rules" { + type = any + default = {} + description = <<-EOT + Map of egress rules to add to the load balancer security group. + Example: + security_group_egress_rules = { + https_out = { + from_port = 443 + to_port = 443 + ip_protocol = "tcp" + cidr_ipv4 = "0.0.0.0/0" + } + } + EOT +} + +variable "access_logs" { + type = object({ + bucket = string + prefix = optional(string) + enabled = optional(bool, true) + }) + default = null + description = "S3 access log delivery configuration. When null, access logging is disabled." +} + +variable "listeners" { + type = any + default = {} + description = <<-EOT + Map of listener configurations to create. Passed directly to the upstream module. + For ALB, define HTTPS and HTTP listeners here. For NLB, define TCP/TLS listeners. + See https://registry.terraform.io/modules/terraform-aws-modules/alb/aws/latest + for full schema documentation. + EOT +} + +variable "target_groups" { + type = any + default = {} + description = <<-EOT + Map of target group configurations to create. Passed directly to the upstream module. + See https://registry.terraform.io/modules/terraform-aws-modules/alb/aws/latest + for full schema documentation. + EOT +} + +variable "web_acl_arn" { + type = string + default = null + description = "ARN of a WAFv2 Web ACL to associate with the load balancer. Only valid for ALB. When null, no WAF association is created." +} diff --git a/infrastructure/modules/alb/versions.tf b/infrastructure/modules/alb/versions.tf new file mode 100644 index 00000000..d2afd5f9 --- /dev/null +++ b/infrastructure/modules/alb/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.5.7" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.28" + } + } +} From 91da27ea6a70ec29a6ea4bfa4f8fb598f2ff7b30 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Tue, 16 Jun 2026 21:57:30 +0100 Subject: [PATCH 006/118] BCSS - 23624 add cloudwatch module --- infrastructure/modules/cloudwatch/README.md | 78 ++++ infrastructure/modules/cloudwatch/context.tf | 376 ++++++++++++++++++ infrastructure/modules/cloudwatch/locals.tf | 27 ++ infrastructure/modules/cloudwatch/main.tf | 117 ++++++ infrastructure/modules/cloudwatch/outputs.tf | 44 ++ .../modules/cloudwatch/variables.tf | 49 +++ infrastructure/modules/cloudwatch/versions.tf | 10 + 7 files changed, 701 insertions(+) create mode 100644 infrastructure/modules/cloudwatch/README.md create mode 100644 infrastructure/modules/cloudwatch/context.tf create mode 100644 infrastructure/modules/cloudwatch/locals.tf create mode 100644 infrastructure/modules/cloudwatch/main.tf create mode 100644 infrastructure/modules/cloudwatch/outputs.tf create mode 100644 infrastructure/modules/cloudwatch/variables.tf create mode 100644 infrastructure/modules/cloudwatch/versions.tf diff --git a/infrastructure/modules/cloudwatch/README.md b/infrastructure/modules/cloudwatch/README.md new file mode 100644 index 00000000..eb529fd5 --- /dev/null +++ b/infrastructure/modules/cloudwatch/README.md @@ -0,0 +1,78 @@ +# CloudWatch + +NHS Screening wrapper around selected submodules from +[terraform-aws-modules/cloudwatch/aws](https://registry.terraform.io/modules/terraform-aws-modules/cloudwatch/aws/latest) +that provides a single module entry point for common CloudWatch log and alarm building blocks. + +## Included submodules + +- `log-group` +- `log-stream` +- `log-metric-filter` +- `metric-alarm` +- `metric-alarms-by-multiple-dimensions` + +## What this module enforces + +| Control | How it is enforced | +| ------- | ------------------ | +| Single entry point | One shared wrapper exposes the requested CloudWatch submodules together | +| Creation gate | Each submodule is gated by `module.this.enabled` and a non-null config object | +| Naming | Names are derived from `module.this.id` | +| Tagging | Log groups and alarms that support tags receive `module.this.tags` | +| Minimal interface | Only the minimal required or functionally necessary configuration is exposed | + +## Usage + +### Complete example + +```hcl +module "cloudwatch" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch?ref=main" + + service = "bcss" + project = "shared-resources" + environment = "prod" + stack = "monitoring" + name = "application" + + log_group = {} + + log_stream = {} + + log_metric_filter = { + pattern = "ERROR" + metric_transformation_name = "ErrorCount" + metric_transformation_namespace = "BCSS/Application" + } + + metric_alarm = { + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + threshold = 10 + } + + metric_alarms_by_multiple_dimensions = { + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + threshold = 10 + dimensions = { + lambda1 = { + FunctionName = "function-one" + } + lambda2 = { + FunctionName = "function-two" + } + } + } +} +``` + +## Conventions + +- Set a submodule object to `null` to skip creating that submodule. +- `log_stream` and `log_metric_filter` depend on `log_group` being configured in the same module call. +- `metric_alarm` and `metric_alarms_by_multiple_dimensions` derive their metric name and namespace from `log_metric_filter`. +- `metric_alarm` uses fixed defaults of `period = "60"` and `statistic = "Sum"`. +- `metric_alarms_by_multiple_dimensions` uses fixed defaults of `period = "60"` and `statistic = "Sum"`. +- CloudWatch log streams and log metric filters do not support tags directly, so only the submodules that accept tags receive `module.this.tags`. \ No newline at end of file diff --git a/infrastructure/modules/cloudwatch/context.tf b/infrastructure/modules/cloudwatch/context.tf new file mode 100644 index 00000000..62befcb0 --- /dev/null +++ b/infrastructure/modules/cloudwatch/context.tf @@ -0,0 +1,376 @@ +# 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" { + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/cloudwatch/locals.tf b/infrastructure/modules/cloudwatch/locals.tf new file mode 100644 index 00000000..f84124ef --- /dev/null +++ b/infrastructure/modules/cloudwatch/locals.tf @@ -0,0 +1,27 @@ +locals { + log_group_name = module.this.id + + log_stream_name = format("%s-stream", module.this.id) + + log_metric_filter_name = format("%s-metric-filter", module.this.id) + + metric_alarm_name = format("%s-alarm", module.this.id) + + metric_alarms_by_multiple_dimensions_name = format("%s-dimension-alarm", module.this.id) +} + +locals { + created_log_group_name = length(trimspace(try(module.log_group.cloudwatch_log_group_name, ""))) > 0 ? module.log_group.cloudwatch_log_group_name : null + + log_stream_log_group_name = local.created_log_group_name + + log_metric_filter_log_group_name = local.created_log_group_name + + metric_alarm_metric_name = try(var.log_metric_filter.metric_transformation_name, null) + + metric_alarm_namespace = try(var.log_metric_filter.metric_transformation_namespace, null) + + metric_alarms_by_multiple_dimensions_metric_name = try(var.log_metric_filter.metric_transformation_name, null) + + metric_alarms_by_multiple_dimensions_namespace = try(var.log_metric_filter.metric_transformation_namespace, null) +} \ No newline at end of file diff --git a/infrastructure/modules/cloudwatch/main.tf b/infrastructure/modules/cloudwatch/main.tf new file mode 100644 index 00000000..d9ea95b1 --- /dev/null +++ b/infrastructure/modules/cloudwatch/main.tf @@ -0,0 +1,117 @@ +################################################################ +# CloudWatch +# +# Thin NHS wrapper around the community CloudWatch submodules that +# provides a single entry point for the most common log and alarm +# building blocks used by screening teams: +# +# * log-group +# * log-stream +# * log-metric-filter +# * metric-alarm +# * metric-alarms-by-multiple-dimensions +# +# Naming and tagging are derived from context.tf via module.this. +################################################################ + +module "log_group" { + source = "terraform-aws-modules/cloudwatch/aws//modules/log-group" + version = "5.7.2" + + create = module.this.enabled && var.log_group != null + + name = local.log_group_name + + tags = module.this.tags +} + +module "log_stream" { + source = "terraform-aws-modules/cloudwatch/aws//modules/log-stream" + version = "5.7.2" + + create = module.this.enabled && var.log_stream != null + + name = local.log_stream_name + log_group_name = local.log_stream_log_group_name +} + +module "log_metric_filter" { + source = "terraform-aws-modules/cloudwatch/aws//modules/log-metric-filter" + version = "5.7.2" + + create_cloudwatch_log_metric_filter = module.this.enabled && var.log_metric_filter != null + + name = local.log_metric_filter_name + log_group_name = local.log_metric_filter_log_group_name + pattern = var.log_metric_filter.pattern + + metric_transformation_name = var.log_metric_filter.metric_transformation_name + metric_transformation_namespace = var.log_metric_filter.metric_transformation_namespace +} + +module "metric_alarm" { + source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm" + version = "5.7.2" + + create_metric_alarm = module.this.enabled && var.metric_alarm != null + + alarm_name = local.metric_alarm_name + comparison_operator = var.metric_alarm.comparison_operator + evaluation_periods = var.metric_alarm.evaluation_periods + threshold = var.metric_alarm.threshold + + metric_name = local.metric_alarm_metric_name + namespace = local.metric_alarm_namespace + period = "60" + statistic = "Sum" + + tags = module.this.tags +} + +module "metric_alarms_by_multiple_dimensions" { + source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions" + version = "5.7.2" + + create_metric_alarm = module.this.enabled && var.metric_alarms_by_multiple_dimensions != null + + alarm_name = local.metric_alarms_by_multiple_dimensions_name + comparison_operator = var.metric_alarms_by_multiple_dimensions.comparison_operator + evaluation_periods = var.metric_alarms_by_multiple_dimensions.evaluation_periods + threshold = var.metric_alarms_by_multiple_dimensions.threshold + + metric_name = local.metric_alarms_by_multiple_dimensions_metric_name + namespace = local.metric_alarms_by_multiple_dimensions_namespace + period = "60" + statistic = "Sum" + dimensions = var.metric_alarms_by_multiple_dimensions.dimensions + + tags = module.this.tags +} + +check "log_stream_log_group_name" { + assert { + condition = var.log_stream == null || var.log_group != null + error_message = "log_stream requires log_group to be configured in the same module call." + } +} + +check "log_metric_filter_log_group_name" { + assert { + condition = var.log_metric_filter == null || var.log_group != null + error_message = "log_metric_filter requires log_group to be configured in the same module call." + } +} + +check "metric_alarm_metric_identity" { + assert { + condition = var.metric_alarm == null || (local.metric_alarm_metric_name != null && local.metric_alarm_namespace != null) + error_message = "metric_alarm requires metric_name and namespace, either directly on metric_alarm or indirectly from log_metric_filter." + } +} + +check "metric_alarms_by_multiple_dimensions_metric_identity" { + assert { + condition = var.metric_alarms_by_multiple_dimensions == null || (local.metric_alarms_by_multiple_dimensions_metric_name != null && local.metric_alarms_by_multiple_dimensions_namespace != null) + error_message = "metric_alarms_by_multiple_dimensions requires metric_name and namespace, either directly on metric_alarms_by_multiple_dimensions or indirectly from log_metric_filter." + } +} \ No newline at end of file diff --git a/infrastructure/modules/cloudwatch/outputs.tf b/infrastructure/modules/cloudwatch/outputs.tf new file mode 100644 index 00000000..b022b4c9 --- /dev/null +++ b/infrastructure/modules/cloudwatch/outputs.tf @@ -0,0 +1,44 @@ +output "cloudwatch_log_group_name" { + description = "Name of the CloudWatch log group, if created." + value = module.log_group.cloudwatch_log_group_name +} + +output "cloudwatch_log_group_arn" { + description = "ARN of the CloudWatch log group, if created." + value = module.log_group.cloudwatch_log_group_arn +} + +output "cloudwatch_log_stream_name" { + description = "Name of the CloudWatch log stream, if created." + value = module.log_stream.cloudwatch_log_stream_name +} + +output "cloudwatch_log_stream_arn" { + description = "ARN of the CloudWatch log stream, if created." + value = module.log_stream.cloudwatch_log_stream_arn +} + +output "cloudwatch_log_metric_filter_id" { + description = "The name of the CloudWatch log metric filter, if created." + value = module.log_metric_filter.cloudwatch_log_metric_filter_id +} + +output "cloudwatch_metric_alarm_id" { + description = "The ID of the CloudWatch metric alarm, if created." + value = module.metric_alarm.cloudwatch_metric_alarm_id +} + +output "cloudwatch_metric_alarm_arn" { + description = "The ARN of the CloudWatch metric alarm, if created." + value = module.metric_alarm.cloudwatch_metric_alarm_arn +} + +output "cloudwatch_metric_alarm_ids" { + description = "Map of CloudWatch metric alarm IDs created by the multiple-dimensions submodule, if configured." + value = module.metric_alarms_by_multiple_dimensions.cloudwatch_metric_alarm_ids +} + +output "cloudwatch_metric_alarm_arns" { + description = "Map of CloudWatch metric alarm ARNs created by the multiple-dimensions submodule, if configured." + value = module.metric_alarms_by_multiple_dimensions.cloudwatch_metric_alarm_arns +} \ No newline at end of file diff --git a/infrastructure/modules/cloudwatch/variables.tf b/infrastructure/modules/cloudwatch/variables.tf new file mode 100644 index 00000000..4ff1c4f4 --- /dev/null +++ b/infrastructure/modules/cloudwatch/variables.tf @@ -0,0 +1,49 @@ +################################################################ +# CloudWatch submodule inputs. +# +# Naming, tagging and the master `enabled` switch come from +# context.tf via `module.this`. +################################################################ + +variable "log_group" { + description = "Configuration for the CloudWatch log group submodule. Set to null to skip creating a log group." + type = object({}) + default = null +} + +variable "log_stream" { + description = "Configuration for the CloudWatch log stream submodule. Set to null to skip creating a log stream." + type = object({}) + default = null +} + +variable "log_metric_filter" { + description = "Configuration for the CloudWatch log metric filter submodule. Set to null to skip creating a log metric filter." + type = object({ + pattern = string + metric_transformation_name = string + metric_transformation_namespace = string + }) + default = null +} + +variable "metric_alarm" { + description = "Configuration for the CloudWatch metric alarm submodule. Set to null to skip creating a metric alarm." + type = object({ + comparison_operator = string + evaluation_periods = number + threshold = number + }) + default = null +} + +variable "metric_alarms_by_multiple_dimensions" { + description = "Configuration for the CloudWatch metric alarms by multiple dimensions submodule. Set to null to skip creating these alarms." + type = object({ + comparison_operator = string + evaluation_periods = number + threshold = number + dimensions = map(map(string)) + }) + default = null +} \ No newline at end of file diff --git a/infrastructure/modules/cloudwatch/versions.tf b/infrastructure/modules/cloudwatch/versions.tf new file mode 100644 index 00000000..d6b7d5f0 --- /dev/null +++ b/infrastructure/modules/cloudwatch/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" + } + } +} \ No newline at end of file From fe0abdddff843129c0345f66df13a2fc83f3960f Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Fri, 12 Jun 2026 14:10:07 +0100 Subject: [PATCH 007/118] Created inital RDS module --- infrastructure/modules/rds/README.md | 151 ++++++++++ infrastructure/modules/rds/context.tf | 374 ++++++++++++++++++++++++ infrastructure/modules/rds/main.tf | 148 ++++++++++ infrastructure/modules/rds/outputs.tf | 82 ++++++ infrastructure/modules/rds/variables.tf | 282 ++++++++++++++++++ infrastructure/modules/rds/versions.tf | 10 + 6 files changed, 1047 insertions(+) create mode 100644 infrastructure/modules/rds/README.md create mode 100644 infrastructure/modules/rds/context.tf create mode 100644 infrastructure/modules/rds/main.tf create mode 100644 infrastructure/modules/rds/outputs.tf create mode 100644 infrastructure/modules/rds/variables.tf create mode 100644 infrastructure/modules/rds/versions.tf diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md new file mode 100644 index 00000000..73ca0294 --- /dev/null +++ b/infrastructure/modules/rds/README.md @@ -0,0 +1,151 @@ +# RDS Module + +Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). + +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. It also creates a security group unless the caller supplies their own via `vpc_security_group_ids`. + +## Fixed controls + +These values are always enforced and cannot be overridden by callers. + +| Control | Value | Reason | +|---------|-------|--------| +| `publicly_accessible` | `false` | Databases must never be internet-facing | +| `storage_encrypted` | `true` | Encryption at rest is mandatory | +| `copy_tags_to_snapshot` | `true` | Snapshots must carry the same tags as the instance | +| `auto_minor_version_upgrade` | `false` | Teams keep instances in sync with the production engine version | +| `create_db_subnet_group` | `true` | Subnet group is always managed by this module | + +## Usage + +### Oracle with a fresh database + +```hcl +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + # context + service = var.service + environment = var.environment + workspace = terraform.workspace + + # identity + identifier = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" + + # engine + engine = "oracle-ee" + engine_version = "19" + license_model = "license-included" + major_engine_version = "19" + family = "oracle-ee-19" + character_set_name = "AL32UTF8" + + # sizing + instance_class = "db.m5.large" + allocated_storage = 100 + storage_type = "gp3" + + # database + db_name = "MYDB" + username = var.rds_master_username + port = 1521 + + # credentials (write-only — not stored in state) + password_wo = var.rds_master_password + password_wo_version = 1 + + # networking + subnet_ids = data.aws_subnets.private.ids + vpc_id = data.aws_vpc.selected.id + pi_port = 1529 + pi_cidr_block = ["10.0.0.0/8"] + + # options (Oracle S3 integration and timezone) + options = [ + { + option_name = "S3_INTEGRATION" + version = "1.0" + port = 0 + vpc_security_group_memberships = [] + option_settings = [] + }, + { + option_name = "Timezone" + port = 0 + vpc_security_group_memberships = [] + option_settings = [ + { name = "TIME_ZONE", value = "Europe/London" } + ] + } + ] + + # parameters + parameters = [ + { name = "_add_col_optim_enabled", value = "TRUE", apply_method = "immediate" } + ] + + # availability and backup + multi_az = var.environment == "prod" ? true : false + backup_retention_period = 7 + skip_final_snapshot = false + deletion_protection = var.environment == "prod" ? true : false +} +``` + +### Restore from snapshot + +```hcl +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + # ... (same as above) + + # When restoring from a snapshot, character_set_name must be null + character_set_name = null + snapshot_identifier = "rds:my-db-2024-01-01-06-00" +} +``` + +### RDS-managed password (no password in Terraform state) + +```hcl +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + # ... + + manage_master_user_password = true + # password_wo is not required when manage_master_user_password = true +} +``` + +The master password ARN is exposed via the `master_user_secret_arn` output. + +## Outputs + +| Name | Description | +|------|-------------| +| `instance_address` | Hostname of the RDS instance (without port) | +| `instance_port` | Port number | +| `instance_endpoint` | Connection endpoint in `host:port` format | +| `instance_id` | RDS instance identifier | +| `instance_arn` | ARN of the RDS instance | +| `instance_resource_id` | RDS resource ID (used for IAM authentication) | +| `master_user_secret_arn` | Secrets Manager ARN for the master password (when `manage_master_user_password = true`) | +| `rds_security_group` | Full security group object (`.id`, `.arn`, etc.) — null when `vpc_security_group_ids` is provided | +| `security_group_id` | Security group ID — null when `vpc_security_group_ids` is provided | +| `rds_subnet_group` | Object with `.id` and `.arn` for the DB subnet group | +| `db_subnet_group_id` | DB subnet group name/ID | +| `db_parameter_group_id` | DB parameter group ID | +| `db_option_group_id` | DB option group ID | +| `enhanced_monitoring_iam_role_arn` | Enhanced Monitoring IAM role ARN (empty when `monitoring_interval = 0`) | + +## Migration from the local bcss `rds` module + +When migrating from `../../modules/rds` in the bcss repo to this shared module, note: + +1. **Output names** — `instance_endpoint`, `instance_address`, `instance_port`, and `rds_security_group` are compatible. `rds_subnet_group` is compatible (both expose an object with `.id`). +2. **`snapshot_identifier`** — The local module used `""` as "no snapshot". This module uses `null`. Update the calling stack. +3. **`password_wo`** — The local module accepted `master_password` as a plain variable (stored in state). This module uses `password_wo` (write-only, not persisted in state). +4. **`deletion_protection`** — Defaults to `true` here (defaults to whatever `var.deletion_protection` was in the local module). Add `#checkov:skip=CKV_AWS_293` to the module call in non-production stacks. +5. **`ignore_changes` lifecycle** — The local module ignored `engine`, `engine_version`, `availability_zone`, `db_subnet_group_name`, and `storage_encrypted` on the `aws_db_instance`. These lifecycle rules are inside the community module and cannot be overridden from a wrapper. Raise this as a known limitation in the migration PR. diff --git a/infrastructure/modules/rds/context.tf b/infrastructure/modules/rds/context.tf new file mode 100644 index 00000000..39d9b945 --- /dev/null +++ b/infrastructure/modules/rds/context.tf @@ -0,0 +1,374 @@ +# +# 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" { + source = "../tags" + + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf new file mode 100644 index 00000000..d4af04c9 --- /dev/null +++ b/infrastructure/modules/rds/main.tf @@ -0,0 +1,148 @@ +data "aws_vpc" "selected" { + id = var.vpc_id +} + +locals { + create_security_group = length(var.vpc_security_group_ids) == 0 + effective_ingress_cidr_blocks = length(var.ingress_cidr_blocks) > 0 ? var.ingress_cidr_blocks : [data.aws_vpc.selected.cidr_block] +} + +# ---------------------------------------------------------------------------- +# Security group for the RDS instance. +# +# Only created when vpc_security_group_ids is not provided. The security group +# allows inbound traffic on the DB port (and optionally the Performance Insights +# agent port) from the VPC CIDR or caller-supplied CIDR blocks, and restricts +# outbound traffic to HTTPS only. +# ---------------------------------------------------------------------------- + +# tflint-ignore: terraform_required_providers +resource "aws_security_group" "this" { + # checkov:skip=CKV2_AWS_5: SG is attached to the RDS instance via vpc_security_group_ids in the community module below + count = local.create_security_group ? 1 : 0 + + name_prefix = "${module.this.id}-rds-" + description = "Allow VPC traffic to ${var.engine} RDS instance on port ${var.port}" + vpc_id = var.vpc_id + + ingress { + description = "DB port from VPC" + from_port = var.port + to_port = var.port + protocol = "tcp" + cidr_blocks = local.effective_ingress_cidr_blocks + } + + dynamic "ingress" { + for_each = var.pi_port != null ? [1] : [] + content { + description = "Performance Insights agent port" + from_port = var.pi_port + to_port = var.pi_port + protocol = "tcp" + cidr_blocks = length(var.pi_cidr_block) > 0 ? var.pi_cidr_block : local.effective_ingress_cidr_blocks + } + } + + egress { + description = "HTTPS egress for AWS service communication" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + + tags = merge(module.this.tags, { Name = "${module.this.id}-rds" }) + + lifecycle { + create_before_destroy = true + } +} + +# ---------------------------------------------------------------------------- +# RDS instance +# +# Wraps terraform-aws-modules/rds/aws v7.2.0. +# +# Fixed controls (not exposed as variables): +# - publicly_accessible = false (databases must never be internet-facing) +# - storage_encrypted = true (encryption at rest is mandatory) +# - copy_tags_to_snapshot = true (snapshots must carry the same tags) +# - auto_minor_version_upgrade = false (teams keep instances in sync with prod) +# - create_db_subnet_group = true (subnet group always managed by this module) +# ---------------------------------------------------------------------------- +module "rds" { + source = "terraform-aws-modules/rds/aws" + version = "7.2.0" + + identifier = var.identifier + + # Engine + engine = var.engine + engine_version = var.engine_version + license_model = var.license_model + character_set_name = var.character_set_name + + # Instance sizing + instance_class = var.instance_class + allocated_storage = var.allocated_storage + max_allocated_storage = var.max_allocated_storage + storage_type = var.storage_type + iops = var.iops + storage_encrypted = true + kms_key_id = var.kms_key_id + + # Database + db_name = var.db_name + username = var.username + port = var.port + + # Credentials + manage_master_user_password = var.manage_master_user_password + password_wo = var.password_wo + password_wo_version = var.password_wo_version + + # Networking + publicly_accessible = false + vpc_security_group_ids = local.create_security_group ? [aws_security_group.this[0].id] : var.vpc_security_group_ids + + # Subnet group (always managed by this module) + create_db_subnet_group = true + subnet_ids = var.subnet_ids + + # Parameter group + family = var.family + parameters = var.parameters + + # Option group + major_engine_version = var.major_engine_version + options = var.options + + # Monitoring + monitoring_interval = var.monitoring_interval + create_monitoring_role = var.monitoring_interval > 0 + + # Availability and backup + multi_az = var.multi_az + backup_retention_period = var.backup_retention_period + backup_window = var.backup_window + maintenance_window = var.maintenance_window + skip_final_snapshot = var.skip_final_snapshot + snapshot_identifier = var.snapshot_identifier + apply_immediately = var.apply_immediately + copy_tags_to_snapshot = true + auto_minor_version_upgrade = false + + # Performance Insights + performance_insights_enabled = var.performance_insights_enabled + performance_insights_retention_period = var.performance_insights_enabled ? var.performance_insights_retention_period : null + performance_insights_kms_key_id = var.performance_insights_kms_key_id + + # Lifecycle + deletion_protection = var.deletion_protection + + timeouts = var.timeouts + + tags = module.this.tags +} diff --git a/infrastructure/modules/rds/outputs.tf b/infrastructure/modules/rds/outputs.tf new file mode 100644 index 00000000..60bf8117 --- /dev/null +++ b/infrastructure/modules/rds/outputs.tf @@ -0,0 +1,82 @@ +# Instance connection details + +output "instance_address" { + description = "Hostname of the RDS instance (without port)" + value = module.rds.db_instance_address +} + +output "instance_port" { + description = "Port on which the RDS instance accepts connections" + value = module.rds.db_instance_port +} + +output "instance_endpoint" { + description = "Connection endpoint for the RDS instance in host:port format" + value = module.rds.db_instance_endpoint +} + +output "instance_id" { + description = "Identifier of the RDS instance" + value = module.rds.db_instance_identifier +} + +output "instance_arn" { + description = "ARN of the RDS instance" + value = module.rds.db_instance_arn +} + +output "instance_resource_id" { + description = "The RDS resource ID (used for IAM authentication and tagging)" + value = module.rds.db_instance_resource_id +} + +output "master_user_secret_arn" { + description = "ARN of the Secrets Manager secret for the master user password. Only populated when manage_master_user_password is true" + value = module.rds.db_instance_master_user_secret_arn +} + +# Security group + +output "rds_security_group" { + description = "The security group created for the RDS instance. Null when vpc_security_group_ids was provided by the caller" + value = local.create_security_group ? aws_security_group.this[0] : null +} + +output "security_group_id" { + description = "ID of the RDS security group. Null when vpc_security_group_ids was provided by the caller" + value = local.create_security_group ? aws_security_group.this[0].id : null +} + +# Subnet group + +output "rds_subnet_group" { + description = "The DB subnet group used by the RDS instance, with id and arn attributes" + value = { + id = module.rds.db_subnet_group_id + arn = module.rds.db_subnet_group_arn + } +} + +output "db_subnet_group_id" { + description = "Name/ID of the DB subnet group" + value = module.rds.db_subnet_group_id +} + +# Parameter and option groups + +output "db_parameter_group_id" { + description = "ID of the DB parameter group" + value = module.rds.db_parameter_group_id +} + +output "db_option_group_id" { + description = "ID of the DB option group" + value = module.rds.db_option_group_id +} + +# Monitoring + +output "enhanced_monitoring_iam_role_arn" { + description = "ARN of the Enhanced Monitoring IAM role. Empty when monitoring_interval is 0" + value = module.rds.enhanced_monitoring_iam_role_arn +} diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf new file mode 100644 index 00000000..e5bb8191 --- /dev/null +++ b/infrastructure/modules/rds/variables.tf @@ -0,0 +1,282 @@ +# ---------------------------------------------------------------------------- +# Instance identity +# ---------------------------------------------------------------------------- + +variable "identifier" { + description = "The name of the RDS instance" + type = string +} + +# ---------------------------------------------------------------------------- +# Engine +# ---------------------------------------------------------------------------- + +variable "engine" { + description = "The database engine to use (e.g. 'oracle-ee', 'postgres', 'mysql')" + type = string +} + +variable "engine_version" { + description = "The engine version to use" + type = string +} + +variable "license_model" { + description = "License model for the DB instance. Required for some engines (e.g. Oracle SE1 requires 'license-included')" + type = string + default = null +} + +variable "character_set_name" { + description = "Oracle character set name. Cannot be changed after creation. Must be null when restoring from a snapshot" + type = string + default = null +} + +# ---------------------------------------------------------------------------- +# Instance sizing +# ---------------------------------------------------------------------------- + +variable "instance_class" { + description = "The instance type of the RDS instance (e.g. 'db.m5.large')" + type = string +} + +variable "allocated_storage" { + description = "The allocated storage in gibibytes" + type = number +} + +variable "max_allocated_storage" { + description = "Upper limit for storage autoscaling in gibibytes. Set to 0 to disable autoscaling" + type = number + default = 0 +} + +variable "storage_type" { + description = "One of 'standard', 'gp2', 'gp3', 'io1', or 'io2'. Defaults to 'io1' when iops is set, otherwise 'gp2'" + type = string + default = null +} + +variable "iops" { + description = "Provisioned IOPS. Required when storage_type is 'io1' or 'io2'" + type = number + default = null +} + +variable "kms_key_id" { + description = "ARN of the KMS key for storage encryption. If omitted, the default account KMS key is used" + type = string + default = null +} + +# ---------------------------------------------------------------------------- +# Credentials +# ---------------------------------------------------------------------------- + +variable "username" { + description = "Username for the master DB user" + type = string + sensitive = true +} + +variable "password_wo" { + description = "Write-only password for the master DB user. Required when manage_master_user_password is false and snapshot_identifier is not set" + type = string + default = null + sensitive = true +} + +variable "password_wo_version" { + description = "Increment this value to trigger a password rotation when password_wo changes" + type = number + default = 1 +} + +variable "manage_master_user_password" { + description = "When true, RDS manages the master password in Secrets Manager. When false, password_wo must be provided" + type = bool + default = false +} + +# ---------------------------------------------------------------------------- +# Database +# ---------------------------------------------------------------------------- + +variable "db_name" { + description = "The name of the database to create. Omit to skip initial database creation" + type = string + default = null +} + +variable "port" { + description = "The port on which the DB accepts connections" + type = number +} + +# ---------------------------------------------------------------------------- +# Networking +# ---------------------------------------------------------------------------- + +variable "subnet_ids" { + description = "List of VPC subnet IDs for the DB subnet group" + type = list(string) +} + +variable "vpc_id" { + description = "VPC ID. Used to derive the VPC CIDR for security group ingress when ingress_cidr_blocks is not set" + type = string +} + +variable "vpc_security_group_ids" { + description = "List of existing security group IDs to associate with the instance. When provided, no security group is created by this module" + type = list(string) + default = [] +} + +variable "ingress_cidr_blocks" { + description = "CIDR blocks allowed to connect on the DB port. Defaults to the VPC CIDR when empty" + type = list(string) + default = [] +} + +variable "pi_port" { + description = "Performance Insights agent port. When set, an additional ingress rule is added to the security group" + type = number + default = null +} + +variable "pi_cidr_block" { + description = "CIDR blocks allowed to connect on the Performance Insights port. Defaults to ingress_cidr_blocks when empty" + type = list(string) + default = [] +} + +# ---------------------------------------------------------------------------- +# Parameter group +# ---------------------------------------------------------------------------- + +variable "family" { + description = "DB parameter group family (e.g. 'oracle-ee-19', 'postgres16', 'mysql8.0')" + type = string +} + +variable "parameters" { + description = "List of DB parameters to apply to the parameter group" + type = list(object({ + name = string + value = string + apply_method = optional(string) + })) + default = [] +} + +# ---------------------------------------------------------------------------- +# Option group +# ---------------------------------------------------------------------------- + +variable "major_engine_version" { + description = "Major engine version for the option group (e.g. '19' for Oracle 19c)" + type = string +} + +variable "options" { + description = "List of option group options to apply. See the community module documentation for the full object shape" + type = any + default = [] +} + +# ---------------------------------------------------------------------------- +# Availability and backup +# ---------------------------------------------------------------------------- + +variable "multi_az" { + description = "Specifies if the RDS instance is Multi-AZ" + type = bool + default = false +} + +variable "backup_retention_period" { + description = "Number of days to retain automated backups. Must be between 0 and 35" + type = number + default = 7 +} + +variable "backup_window" { + description = "Daily UTC time range for automated backups (e.g. '23:00-23:30'). Must not overlap with maintenance_window" + type = string + default = "23:00-23:30" +} + +variable "maintenance_window" { + description = "Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00')" + type = string + default = "Sun:00:00-Sun:03:00" +} + +variable "skip_final_snapshot" { + description = "If true, no final snapshot is created on deletion. Should be false in production" + type = bool + default = false +} + +variable "snapshot_identifier" { + description = "Snapshot ID to restore the instance from. When set, character_set_name must be null" + type = string + default = null +} + +variable "apply_immediately" { + description = "Apply modifications immediately rather than deferring to the next maintenance window" + type = bool + default = false +} + +# ---------------------------------------------------------------------------- +# Monitoring and performance +# ---------------------------------------------------------------------------- + +variable "monitoring_interval" { + description = "Interval in seconds between Enhanced Monitoring data points. Valid values: 0, 1, 5, 10, 15, 30, 60. Set to 0 to disable" + type = number + default = 5 +} + +variable "performance_insights_enabled" { + description = "Enable Performance Insights" + type = bool + default = true +} + +variable "performance_insights_retention_period" { + description = "Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31" + type = number + default = 7 +} + +variable "performance_insights_kms_key_id" { + description = "ARN of the KMS key used to encrypt Performance Insights data. If omitted, the default KMS key is used" + type = string + default = null +} + +# ---------------------------------------------------------------------------- +# Lifecycle +# ---------------------------------------------------------------------------- + +variable "deletion_protection" { + description = "Prevents the DB instance from being deleted when true. Should be true in production" + type = bool + default = true +} + +variable "timeouts" { + description = "Terraform resource management timeouts for the DB instance" + type = object({ + create = optional(string) + update = optional(string) + delete = optional(string) + }) + default = null +} diff --git a/infrastructure/modules/rds/versions.tf b/infrastructure/modules/rds/versions.tf new file mode 100644 index 00000000..aaa5e443 --- /dev/null +++ b/infrastructure/modules/rds/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.1" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.28" + } + } +} From 921789baf9f847f527caf93b2091ad29be5b8198 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 17 Jun 2026 17:04:38 +0100 Subject: [PATCH 008/118] Made PR improvements --- infrastructure/modules/rds/README.md | 12 ++--- infrastructure/modules/rds/main.tf | 64 +------------------------ infrastructure/modules/rds/outputs.tf | 12 ----- infrastructure/modules/rds/variables.tf | 25 +--------- 4 files changed, 6 insertions(+), 107 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 73ca0294..1c010544 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -2,7 +2,7 @@ Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). -The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. It also creates a security group unless the caller supplies their own via `vpc_security_group_ids`. +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. ## Fixed controls @@ -55,10 +55,8 @@ module "oracle_rds" { password_wo_version = 1 # networking - subnet_ids = data.aws_subnets.private.ids - vpc_id = data.aws_vpc.selected.id - pi_port = 1529 - pi_cidr_block = ["10.0.0.0/8"] + subnet_ids = data.aws_subnets.private.ids + vpc_security_group_ids = [module.rds_security_group.security_group_id] # options (Oracle S3 integration and timezone) options = [ @@ -132,8 +130,6 @@ The master password ARN is exposed via the `master_user_secret_arn` output. | `instance_arn` | ARN of the RDS instance | | `instance_resource_id` | RDS resource ID (used for IAM authentication) | | `master_user_secret_arn` | Secrets Manager ARN for the master password (when `manage_master_user_password = true`) | -| `rds_security_group` | Full security group object (`.id`, `.arn`, etc.) — null when `vpc_security_group_ids` is provided | -| `security_group_id` | Security group ID — null when `vpc_security_group_ids` is provided | | `rds_subnet_group` | Object with `.id` and `.arn` for the DB subnet group | | `db_subnet_group_id` | DB subnet group name/ID | | `db_parameter_group_id` | DB parameter group ID | @@ -144,7 +140,7 @@ The master password ARN is exposed via the `master_user_secret_arn` output. When migrating from `../../modules/rds` in the bcss repo to this shared module, note: -1. **Output names** — `instance_endpoint`, `instance_address`, `instance_port`, and `rds_security_group` are compatible. `rds_subnet_group` is compatible (both expose an object with `.id`). +1. **Output names** — `instance_endpoint`, `instance_address`, `instance_port`, and `rds_subnet_group` are compatible with the local module. `rds_security_group` is no longer an output — the caller now owns the security group resource. 2. **`snapshot_identifier`** — The local module used `""` as "no snapshot". This module uses `null`. Update the calling stack. 3. **`password_wo`** — The local module accepted `master_password` as a plain variable (stored in state). This module uses `password_wo` (write-only, not persisted in state). 4. **`deletion_protection`** — Defaults to `true` here (defaults to whatever `var.deletion_protection` was in the local module). Add `#checkov:skip=CKV_AWS_293` to the module call in non-production stacks. diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index d4af04c9..45b45d53 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -1,65 +1,3 @@ -data "aws_vpc" "selected" { - id = var.vpc_id -} - -locals { - create_security_group = length(var.vpc_security_group_ids) == 0 - effective_ingress_cidr_blocks = length(var.ingress_cidr_blocks) > 0 ? var.ingress_cidr_blocks : [data.aws_vpc.selected.cidr_block] -} - -# ---------------------------------------------------------------------------- -# Security group for the RDS instance. -# -# Only created when vpc_security_group_ids is not provided. The security group -# allows inbound traffic on the DB port (and optionally the Performance Insights -# agent port) from the VPC CIDR or caller-supplied CIDR blocks, and restricts -# outbound traffic to HTTPS only. -# ---------------------------------------------------------------------------- - -# tflint-ignore: terraform_required_providers -resource "aws_security_group" "this" { - # checkov:skip=CKV2_AWS_5: SG is attached to the RDS instance via vpc_security_group_ids in the community module below - count = local.create_security_group ? 1 : 0 - - name_prefix = "${module.this.id}-rds-" - description = "Allow VPC traffic to ${var.engine} RDS instance on port ${var.port}" - vpc_id = var.vpc_id - - ingress { - description = "DB port from VPC" - from_port = var.port - to_port = var.port - protocol = "tcp" - cidr_blocks = local.effective_ingress_cidr_blocks - } - - dynamic "ingress" { - for_each = var.pi_port != null ? [1] : [] - content { - description = "Performance Insights agent port" - from_port = var.pi_port - to_port = var.pi_port - protocol = "tcp" - cidr_blocks = length(var.pi_cidr_block) > 0 ? var.pi_cidr_block : local.effective_ingress_cidr_blocks - } - } - - egress { - description = "HTTPS egress for AWS service communication" - from_port = 443 - to_port = 443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = ["::/0"] - } - - tags = merge(module.this.tags, { Name = "${module.this.id}-rds" }) - - lifecycle { - create_before_destroy = true - } -} - # ---------------------------------------------------------------------------- # RDS instance # @@ -105,7 +43,7 @@ module "rds" { # Networking publicly_accessible = false - vpc_security_group_ids = local.create_security_group ? [aws_security_group.this[0].id] : var.vpc_security_group_ids + vpc_security_group_ids = var.vpc_security_group_ids # Subnet group (always managed by this module) create_db_subnet_group = true diff --git a/infrastructure/modules/rds/outputs.tf b/infrastructure/modules/rds/outputs.tf index 60bf8117..7d642c75 100644 --- a/infrastructure/modules/rds/outputs.tf +++ b/infrastructure/modules/rds/outputs.tf @@ -35,18 +35,6 @@ output "master_user_secret_arn" { value = module.rds.db_instance_master_user_secret_arn } -# Security group - -output "rds_security_group" { - description = "The security group created for the RDS instance. Null when vpc_security_group_ids was provided by the caller" - value = local.create_security_group ? aws_security_group.this[0] : null -} - -output "security_group_id" { - description = "ID of the RDS security group. Null when vpc_security_group_ids was provided by the caller" - value = local.create_security_group ? aws_security_group.this[0].id : null -} - # Subnet group output "rds_subnet_group" { diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index e5bb8191..d5fb751f 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -124,31 +124,8 @@ variable "subnet_ids" { type = list(string) } -variable "vpc_id" { - description = "VPC ID. Used to derive the VPC CIDR for security group ingress when ingress_cidr_blocks is not set" - type = string -} - variable "vpc_security_group_ids" { - description = "List of existing security group IDs to associate with the instance. When provided, no security group is created by this module" - type = list(string) - default = [] -} - -variable "ingress_cidr_blocks" { - description = "CIDR blocks allowed to connect on the DB port. Defaults to the VPC CIDR when empty" - type = list(string) - default = [] -} - -variable "pi_port" { - description = "Performance Insights agent port. When set, an additional ingress rule is added to the security group" - type = number - default = null -} - -variable "pi_cidr_block" { - description = "CIDR blocks allowed to connect on the Performance Insights port. Defaults to ingress_cidr_blocks when empty" + description = "List of security group IDs to associate with the instance. Create the security group using the dedicated security group module and pass its ID here" type = list(string) default = [] } From 07288efcdf4eed300d7593103059e7c83d00fbcc Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 15:13:20 +0100 Subject: [PATCH 009/118] feat(rds): enhance module functionality and documentation - Added support for a new RDS module path in dependabot.yaml. - Created .terraform.lock.hcl for the new RDS module. - Updated README.md to include usage examples for PostgreSQL and Oracle RDS instances, along with security group and secrets manager integration. - Modified context.tf to enable module creation based on the 'enabled' variable. - Introduced locals.tf to define rds_identifier based on provided names. - Updated main.tf to conditionally create RDS resources based on the 'enabled' variable. - Enhanced variables.tf to include custom_name variable for explicit RDS instance naming. - Updated versions.tf to require Terraform version >= 1.13 and AWS provider version >= 6.42. --- .github/dependabot.yaml | 1 + .../modules/rds/.terraform.lock.hcl | 55 ++++ infrastructure/modules/rds/README.md | 305 ++++++++++++++++-- infrastructure/modules/rds/context.tf | 2 + infrastructure/modules/rds/locals.tf | 4 + infrastructure/modules/rds/main.tf | 12 +- infrastructure/modules/rds/variables.tf | 9 +- infrastructure/modules/rds/versions.tf | 4 +- 8 files changed, 356 insertions(+), 36 deletions(-) create mode 100644 infrastructure/modules/rds/.terraform.lock.hcl create mode 100644 infrastructure/modules/rds/locals.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 02b50d0a..13604b7d 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -58,6 +58,7 @@ updates: - "infrastructure/modules/rds-gateway-ecs-task" - "infrastructure/modules/rds-instance" - "infrastructure/modules/rds-users" + - "infrastructure/modules/rds" - "infrastructure/modules/s3-bucket" - "infrastructure/modules/s3" - "infrastructure/modules/secrets-manager" diff --git a/infrastructure/modules/rds/.terraform.lock.hcl b/infrastructure/modules/rds/.terraform.lock.hcl new file mode 100644 index 00000000..61bf386e --- /dev/null +++ b/infrastructure/modules/rds/.terraform.lock.hcl @@ -0,0 +1,55 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.14.0, >= 6.28.0, >= 6.42.0" + hashes = [ + "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:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = ">= 3.1.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/rds/README.md b/infrastructure/modules/rds/README.md index 1c010544..437988f3 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -2,22 +2,53 @@ Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). -The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring iam role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. -## Fixed controls +## What this module enforces -These values are always enforced and cannot be overridden by callers. - -| Control | Value | Reason | -|---------|-------|--------| -| `publicly_accessible` | `false` | Databases must never be internet-facing | -| `storage_encrypted` | `true` | Encryption at rest is mandatory | -| `copy_tags_to_snapshot` | `true` | Snapshots must carry the same tags as the instance | -| `auto_minor_version_upgrade` | `false` | Teams keep instances in sync with the production engine version | -| `create_db_subnet_group` | `true` | Subnet group is always managed by this module | +|Control|Value|Reason| +|-|-|-| +|`publicly_accessible`|`false`|Databases must never be internet-facing| +|`storage_encrypted`|`true`|Encryption at rest is mandatory| +|`copy_tags_to_snapshot`|`true`|Snapshots must carry the same tags as the instance| +|`auto_minor_version_upgrade`|`false`|Teams keep instances in sync with the production engine version| +|`create_db_subnet_group`|`true`|subnet group is always managed by this module| +|Creation gate|`module.this.enabled`|Prevents all managed RDS resources when disabled| ## Usage +### Minimal PostgreSQL instance + +```hcl +module "postgres_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + service = "bcss" + project = "screening" + environment = "dev" + stack = "database" + workspace = terraform.workspace + + engine = "postgres" + engine_version = "16" + major_engine_version = "16" + family = "postgres16" + + instance_class = "db.t4g.medium" + allocated_storage = 100 + + db_name = "screening" + username = var.rds_master_username + port = 5432 + + password_wo = var.rds_master_password + password_wo_version = 1 + + subnet_ids = data.aws_subnets.private.ids + vpc_security_group_ids = [module.rds_security_group.security_group_id] +} +``` + ### Oracle with a fresh database ```hcl @@ -29,8 +60,9 @@ module "oracle_rds" { environment = var.environment workspace = terraform.workspace - # identity - identifier = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" + # identity (optional) + # If omitted, the module derives a name from context labels. + custom_name = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" # engine engine = "oracle-ee" @@ -117,24 +149,121 @@ module "oracle_rds" { } ``` -The master password ARN is exposed via the `master_user_secret_arn` output. +### With security-group and secrets-manager modules -## Outputs +This example shows the recommended pattern for production use: integrating with the dedicated security-group and secrets-manager modules. -| Name | Description | -|------|-------------| -| `instance_address` | Hostname of the RDS instance (without port) | -| `instance_port` | Port number | -| `instance_endpoint` | Connection endpoint in `host:port` format | -| `instance_id` | RDS instance identifier | -| `instance_arn` | ARN of the RDS instance | -| `instance_resource_id` | RDS resource ID (used for IAM authentication) | -| `master_user_secret_arn` | Secrets Manager ARN for the master password (when `manage_master_user_password = true`) | -| `rds_subnet_group` | Object with `.id` and `.arn` for the DB subnet group | -| `db_subnet_group_id` | DB subnet group name/ID | -| `db_parameter_group_id` | DB parameter group ID | -| `db_option_group_id` | DB option group ID | -| `enhanced_monitoring_iam_role_arn` | Enhanced Monitoring IAM role ARN (empty when `monitoring_interval = 0`) | +```hcl +# Create a security group using the NHS security-group wrapper module +module "rds_security_group" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/security-group?ref=vX.Y.Z"" + + service = "bcss" + project = "screening" + environment = var.environment + workspace = terraform.workspace + + vpc_id = data.aws_vpc.private.id + description = "Security group for RDS database" + + # Allow inbound on Oracle port from application security group + ingress_rules = [ + { + from_port = 1521 + to_port = 1521 + protocol = "tcp" + security_groups = [module.app_security_group.security_group_id] + description = "Oracle from application" + } + ] +} + +# Store the master password in Secrets Manager +module "rds_secret" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/secrets-manager?ref=vX.Y.Z" + + service = "bcss" + project = "screening" + environment = var.environment + workspace = terraform.workspace + name = "rds-master-password" + + secret_string = var.rds_master_password + # Optionally: managed rotation, encryption key, tags, etc. +} + +# Create the RDS instance with the security group and managed password +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + service = "bcss" + project = "screening" + environment = var.environment + workspace = terraform.workspace + stack = "database" + + engine = "oracle-ee" + engine_version = "19" + license_model = "license-included" + major_engine_version = "19" + family = "oracle-ee-19" + character_set_name = "AL32UTF8" + + instance_class = "db.m5.large" + allocated_storage = 500 + storage_type = "gp3" + + db_name = "MYDB" + username = var.rds_master_username + port = 1521 + + # Use RDS Secrets Manager integration for password management + manage_master_user_password = true + # The password is automatically stored in Secrets Manager by RDS + # Retrieve it via module output: module.oracle_rds.master_user_secret_arn + + subnet_ids = data.aws_subnets.private.ids + # Pass the security group created above + vpc_security_group_ids = [module.rds_security_group.security_group_id] + + multi_az = var.environment == "prod" + backup_retention_period = 7 + skip_final_snapshot = false + deletion_protection = var.environment == "prod" + + depends_on = [module.rds_security_group] +} + +# Output the secret arn for application connection strings +output "rds_secret_arn" { + value = module.rds_secret.secret_arn + description = "arn of the RDS master password secret" +} + +output "rds_endpoint" { + value = module.oracle_rds.instance_endpoint + description = "RDS instance endpoint (host:port)" +} +``` + +The master password arn is exposed via the `master_user_secret_arn` output. + +## Conventions + +- Naming and tagging come from shared `context.tf` via `module.this`. +- Identifier resolution order is `custom_name`, then `identifier`, then `module.this.id`. +- Security groups are intentionally caller-managed. This module associates IDs passed via `vpc_security_group_ids`. +- Resource creation is gated by `module.this.enabled`. +- Snapshot tagging is always enabled via `copy_tags_to_snapshot = true`. +- Resource arn values (e.g., `instance_arn`) are exposed as output attributes. +- iam authentication and resource tagging require the instance resource ID. + +## What this module does NOT do + +- Create or manage security groups. +- Allow public internet access to the database instance. +- Disable encryption at rest. +- Enable automatic minor engine upgrades. ## Migration from the local bcss `rds` module @@ -145,3 +274,121 @@ When migrating from `../../modules/rds` in the bcss repo to this shared module, 3. **`password_wo`** — The local module accepted `master_password` as a plain variable (stored in state). This module uses `password_wo` (write-only, not persisted in state). 4. **`deletion_protection`** — Defaults to `true` here (defaults to whatever `var.deletion_protection` was in the local module). Add `#checkov:skip=CKV_AWS_293` to the module call in non-production stacks. 5. **`ignore_changes` lifecycle** — The local module ignored `engine`, `engine_version`, `availability_zone`, `db_subnet_group_name`, and `storage_encrypted` on the `aws_db_instance`. These lifecycle rules are inside the community module and cannot be overridden from a wrapper. Raise this as a known limitation in the migration PR. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [rds](#module\_rds) | terraform-aws-modules/rds/aws | 7.2.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 | +| [allocated\_storage](#input\_allocated\_storage) | The allocated storage in gibibytes | `number` | n/a | yes | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [apply\_immediately](#input\_apply\_immediately) | Apply modifications immediately rather than deferring to the next maintenance window | `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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [backup\_retention\_period](#input\_backup\_retention\_period) | Number of days to retain automated backups. Must be between 0 and 35 | `number` | `7` | no | +| [backup\_window](#input\_backup\_window) | Daily UTC time range for automated backups (e.g. '23:00-23:30'). Must not overlap with maintenance\_window | `string` | `"23:00-23:30"` | no | +| [character\_set\_name](#input\_character\_set\_name) | Oracle character set name. Cannot be changed after creation. Must be null when restoring from a snapshot | `string` | `null` | 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 | +| [custom\_name](#input\_custom\_name) | Optional override name for the RDS instance. Takes precedence over identifier when set. | `string` | `null` | no | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | +| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | +| [db\_name](#input\_db\_name) | The name of the database to create. Omit to skip initial database creation | `string` | `null` | no | +| [deletion\_protection](#input\_deletion\_protection) | Prevents the DB instance from being deleted when true. Should be true in production | `bool` | `true` | 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 | +| [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) | The database engine to use (e.g. 'oracle-ee', 'postgres', 'mysql') | `string` | n/a | yes | +| [engine\_version](#input\_engine\_version) | The engine version to use | `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 | +| [family](#input\_family) | DB parameter group family (e.g. 'oracle-ee-19', 'postgres16', 'mysql8.0') | `string` | n/a | yes | +| [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 | +| [identifier](#input\_identifier) | Explicit name for the RDS instance. If null, this module derives the name from context labels. | `string` | `null` | no | +| [instance\_class](#input\_instance\_class) | The instance type of the RDS instance (e.g. 'db.m5.large') | `string` | n/a | yes | +| [iops](#input\_iops) | Provisioned IOPS. Required when storage\_type is 'io1' or 'io2' | `number` | `null` | no | +| [kms\_key\_id](#input\_kms\_key\_id) | ARN of the KMS key for storage encryption. If omitted, the default account KMS key is used | `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 | +| [license\_model](#input\_license\_model) | License model for the DB instance. Required for some engines (e.g. Oracle SE1 requires 'license-included') | `string` | `null` | no | +| [maintenance\_window](#input\_maintenance\_window) | Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00') | `string` | `"Sun:00:00-Sun:03:00"` | no | +| [major\_engine\_version](#input\_major\_engine\_version) | Major engine version for the option group (e.g. '19' for Oracle 19c) | `string` | n/a | yes | +| [manage\_master\_user\_password](#input\_manage\_master\_user\_password) | When true, RDS manages the master password in Secrets Manager. When false, password\_wo must be provided | `bool` | `false` | no | +| [max\_allocated\_storage](#input\_max\_allocated\_storage) | Upper limit for storage autoscaling in gibibytes. Set to 0 to disable autoscaling | `number` | `0` | no | +| [monitoring\_interval](#input\_monitoring\_interval) | Interval in seconds between Enhanced Monitoring data points. Valid values: 0, 1, 5, 10, 15, 30, 60. Set to 0 to disable | `number` | `5` | no | +| [multi\_az](#input\_multi\_az) | Specifies if the RDS instance is Multi-AZ | `bool` | `false` | 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 | +| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [options](#input\_options) | List of option group options to apply. See the community module documentation for the full object shape | `any` | `[]` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [parameters](#input\_parameters) | List of DB parameters to apply to the parameter group |
list(object({
name = string
value = string
apply_method = optional(string)
}))
| `[]` | no | +| [password\_wo](#input\_password\_wo) | Write-only password for the master DB user. Required when manage\_master\_user\_password is false and snapshot\_identifier is not set | `string` | `null` | no | +| [password\_wo\_version](#input\_password\_wo\_version) | Increment this value to trigger a password rotation when password\_wo changes | `number` | `1` | no | +| [performance\_insights\_enabled](#input\_performance\_insights\_enabled) | Enable Performance Insights | `bool` | `true` | no | +| [performance\_insights\_kms\_key\_id](#input\_performance\_insights\_kms\_key\_id) | ARN of the KMS key used to encrypt Performance Insights data. If omitted, the default KMS key is used | `string` | `null` | no | +| [performance\_insights\_retention\_period](#input\_performance\_insights\_retention\_period) | Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31 | `number` | `7` | no | +| [port](#input\_port) | The port on which the DB accepts connections | `number` | n/a | yes | +| [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 | +| [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 | +| [skip\_final\_snapshot](#input\_skip\_final\_snapshot) | If true, no final snapshot is created on deletion. Should be false in production | `bool` | `false` | no | +| [snapshot\_identifier](#input\_snapshot\_identifier) | Snapshot ID to restore the instance from. When set, character\_set\_name must be null | `string` | `null` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [storage\_type](#input\_storage\_type) | One of 'standard', 'gp2', 'gp3', 'io1', or 'io2'. Defaults to 'io1' when iops is set, otherwise 'gp2' | `string` | `null` | no | +| [subnet\_ids](#input\_subnet\_ids) | List of VPC subnet IDs for the DB subnet group | `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 this module path. | `string` | `null` | no | +| [timeouts](#input\_timeouts) | Terraform resource management timeouts for the DB instance |
object({
create = optional(string)
update = optional(string)
delete = optional(string)
})
| `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [username](#input\_username) | Username for the master DB user | `string` | n/a | yes | +| [vpc\_security\_group\_ids](#input\_vpc\_security\_group\_ids) | List of security group IDs to associate with the instance. Create the security group using the dedicated security group module and pass its ID here | `list(string)` | `[]` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [db\_option\_group\_id](#output\_db\_option\_group\_id) | ID of the DB option group | +| [db\_parameter\_group\_id](#output\_db\_parameter\_group\_id) | ID of the DB parameter group | +| [db\_subnet\_group\_id](#output\_db\_subnet\_group\_id) | Name/ID of the DB subnet group | +| [enhanced\_monitoring\_iam\_role\_arn](#output\_enhanced\_monitoring\_iam\_role\_arn) | ARN of the Enhanced Monitoring IAM role. Empty when monitoring\_interval is 0 | +| [instance\_address](#output\_instance\_address) | Hostname of the RDS instance (without port) | +| [instance\_arn](#output\_instance\_arn) | ARN of the RDS instance | +| [instance\_endpoint](#output\_instance\_endpoint) | Connection endpoint for the RDS instance in host:port format | +| [instance\_id](#output\_instance\_id) | Identifier of the RDS instance | +| [instance\_port](#output\_instance\_port) | Port on which the RDS instance accepts connections | +| [instance\_resource\_id](#output\_instance\_resource\_id) | The RDS resource ID (used for IAM authentication and tagging) | +| [master\_user\_secret\_arn](#output\_master\_user\_secret\_arn) | ARN of the Secrets Manager secret for the master user password. Only populated when manage\_master\_user\_password is true | +| [rds\_subnet\_group](#output\_rds\_subnet\_group) | The DB subnet group used by the RDS instance, with id and arn attributes | + + + diff --git a/infrastructure/modules/rds/context.tf b/infrastructure/modules/rds/context.tf index 39d9b945..62befcb0 100644 --- a/infrastructure/modules/rds/context.tf +++ b/infrastructure/modules/rds/context.tf @@ -1,3 +1,4 @@ +# 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 @@ -23,6 +24,7 @@ module "this" { source = "../tags" + enabled = var.enabled service = var.service project = var.project region = var.region diff --git a/infrastructure/modules/rds/locals.tf b/infrastructure/modules/rds/locals.tf new file mode 100644 index 00000000..0f350376 --- /dev/null +++ b/infrastructure/modules/rds/locals.tf @@ -0,0 +1,4 @@ +locals { + # Prefer explicit caller names when provided, otherwise derive from context labels. + rds_identifier = coalesce(var.custom_name, var.identifier, module.this.id) +} diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 45b45d53..32c8cfce 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -14,7 +14,12 @@ module "rds" { source = "terraform-aws-modules/rds/aws" version = "7.2.0" - identifier = var.identifier + create_db_instance = module.this.enabled + create_db_subnet_group = module.this.enabled + create_db_parameter_group = module.this.enabled + create_db_option_group = module.this.enabled + + identifier = local.rds_identifier # Engine engine = var.engine @@ -46,8 +51,7 @@ module "rds" { vpc_security_group_ids = var.vpc_security_group_ids # Subnet group (always managed by this module) - create_db_subnet_group = true - subnet_ids = var.subnet_ids + subnet_ids = var.subnet_ids # Parameter group family = var.family @@ -59,7 +63,7 @@ module "rds" { # Monitoring monitoring_interval = var.monitoring_interval - create_monitoring_role = var.monitoring_interval > 0 + create_monitoring_role = module.this.enabled && var.monitoring_interval > 0 # Availability and backup multi_az = var.multi_az diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index d5fb751f..83bee1b3 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -3,8 +3,15 @@ # ---------------------------------------------------------------------------- variable "identifier" { - description = "The name of the RDS instance" + description = "Explicit name for the RDS instance. If null, this module derives the name from context labels." type = string + default = null +} + +variable "custom_name" { + description = "Optional override name for the RDS instance. Takes precedence over identifier when set." + type = string + default = null } # ---------------------------------------------------------------------------- diff --git a/infrastructure/modules/rds/versions.tf b/infrastructure/modules/rds/versions.tf index aaa5e443..cb30fe5c 100644 --- a/infrastructure/modules/rds/versions.tf +++ b/infrastructure/modules/rds/versions.tf @@ -1,10 +1,10 @@ terraform { - required_version = ">= 1.11.1" + required_version = ">= 1.13" required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.28" + version = ">= 6.42" } } } From ab0da5e7f20022c301f934a648859d0fd347659c Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Fri, 19 Jun 2026 09:20:27 +0100 Subject: [PATCH 010/118] feat(ecs-service): add bare bones service --- .github/dependabot.yaml | 1 + .../modules/ecs-service/.terraform.lock.hcl | 30 ++++++++++++++++ infrastructure/modules/ecs-service/README.md | 36 +++++++++++++++++++ infrastructure/modules/ecs-service/main.tf | 4 +++ infrastructure/modules/ecs-service/outputs.tf | 1 + .../modules/ecs-service/variables.tf | 1 + .../modules/ecs-service/versions.tf | 10 ++++++ 7 files changed, 83 insertions(+) create mode 100644 infrastructure/modules/ecs-service/.terraform.lock.hcl create mode 100644 infrastructure/modules/ecs-service/README.md create mode 100644 infrastructure/modules/ecs-service/main.tf create mode 100644 infrastructure/modules/ecs-service/outputs.tf create mode 100644 infrastructure/modules/ecs-service/variables.tf create mode 100644 infrastructure/modules/ecs-service/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 229165e4..fc0bf145 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -44,6 +44,7 @@ updates: - "infrastructure/modules/cw-firehose-splunk" - "infrastructure/modules/ecr" - "infrastructure/modules/ecs-cluster" + - "infrastructure/modules/ecs-service" - "infrastructure/modules/elasticache" - "infrastructure/modules/github-config" - "infrastructure/modules/guardduty" diff --git a/infrastructure/modules/ecs-service/.terraform.lock.hcl b/infrastructure/modules/ecs-service/.terraform.lock.hcl new file mode 100644 index 00000000..eb4f3fd1 --- /dev/null +++ b/infrastructure/modules/ecs-service/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.34.0" + hashes = [ + "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:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md new file mode 100644 index 00000000..77bae952 --- /dev/null +++ b/infrastructure/modules/ecs-service/README.md @@ -0,0 +1,36 @@ +# DAVEH + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.34 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [ecs\_service](#module\_ecs\_service) | terraform-aws-modules/ecs/aws//modules/service | ~> 7.5.0 | + +## Resources + +No resources. + +## Inputs + +No inputs. + +## Outputs + +No outputs. + + + diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf new file mode 100644 index 00000000..359595df --- /dev/null +++ b/infrastructure/modules/ecs-service/main.tf @@ -0,0 +1,4 @@ +module "ecs_service" { + source = "terraform-aws-modules/ecs/aws//modules/service" + version = "~> 7.5.0" +} diff --git a/infrastructure/modules/ecs-service/outputs.tf b/infrastructure/modules/ecs-service/outputs.tf new file mode 100644 index 00000000..c9fb5240 --- /dev/null +++ b/infrastructure/modules/ecs-service/outputs.tf @@ -0,0 +1 @@ +# DAVEH diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf new file mode 100644 index 00000000..c9fb5240 --- /dev/null +++ b/infrastructure/modules/ecs-service/variables.tf @@ -0,0 +1 @@ +# DAVEH diff --git a/infrastructure/modules/ecs-service/versions.tf b/infrastructure/modules/ecs-service/versions.tf new file mode 100644 index 00000000..542c57d5 --- /dev/null +++ b/infrastructure/modules/ecs-service/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.34" + } + } +} From 82d768195c2b97e8186334c113c9d108803ab319 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 08:20:32 +0100 Subject: [PATCH 011/118] feat(ecs-service): add context --- infrastructure/modules/ecs-service/README.md | 35 +- infrastructure/modules/ecs-service/context.tf | 376 ++++++++++++++++++ infrastructure/modules/ecs-service/main.tf | 4 + 3 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 infrastructure/modules/ecs-service/context.tf diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 77bae952..d5aaab0e 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -19,6 +19,7 @@ No providers. | Name | Source | Version | | ---- | ------ | ------- | | [ecs\_service](#module\_ecs\_service) | terraform-aws-modules/ecs/aws//modules/service | ~> 7.5.0 | +| [this](#module\_this) | ../tags | n/a | ## Resources @@ -26,7 +27,39 @@ No resources. ## Inputs -No 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 | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | 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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | +| [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 | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | +| [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 | +| [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 | +| [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 | +| [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 | +| [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 | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [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 this module path. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | ## Outputs diff --git a/infrastructure/modules/ecs-service/context.tf b/infrastructure/modules/ecs-service/context.tf new file mode 100644 index 00000000..62befcb0 --- /dev/null +++ b/infrastructure/modules/ecs-service/context.tf @@ -0,0 +1,376 @@ +# 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" { + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 359595df..0fc4ded1 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -1,4 +1,8 @@ module "ecs_service" { source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 7.5.0" + + create = module.this.enabled + name = module.this.name + tags = module.this.tags } From b8551b25c46f9a062638f5e643453823ced89139 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 08:41:35 +0100 Subject: [PATCH 012/118] feat(ecs-service): add inputs a --- infrastructure/modules/ecs-service/README.md | 8 + infrastructure/modules/ecs-service/main.tf | 9 + .../modules/ecs-service/variables.tf | 214 +++++++++++++++++- 3 files changed, 230 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index d5aaab0e..033cf591 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -30,8 +30,16 @@ 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 | +| [alarms](#input\_alarms) | Information about the CloudWatch alarms |
object({
alarm_names = list(string)
enable = optional(bool, true)
rollback = optional(bool, true)
})
| `null` | no | | [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [assign\_public\_ip](#input\_assign\_public\_ip) | Assign a public IP address to the ENI (Fargate launch type only) | `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 | +| [autoscaling\_max\_capacity](#input\_autoscaling\_max\_capacity) | Maximum number of tasks to run in your service | `number` | `10` | no | +| [autoscaling\_min\_capacity](#input\_autoscaling\_min\_capacity) | Minimum number of tasks to run in your service | `number` | `1` | no | +| [autoscaling\_policies](#input\_autoscaling\_policies) | Map of autoscaling policies to create for the service |
map(object({
name = optional(string) # Will fall back to the key name if not provided
policy_type = optional(string, "TargetTrackingScaling")
predictive_scaling_policy_configuration = optional(object({
max_capacity_breach_behavior = optional(string)
max_capacity_buffer = optional(number)
metric_specification = list(object({
customized_capacity_metric_specification = optional(object({
metric_data_query = list(object({
expression = optional(string)
id = string
label = optional(string)
metric_stat = optional(object({
metric = object({
dimension = optional(list(object({
name = string
value = string
})))
metric_name = optional(string)
namespace = optional(string)
})
stat = string
unit = optional(string)
}))
return_data = optional(bool)
}))
}))
customized_load_metric_specification = optional(object({
metric_data_query = list(object({
expression = optional(string)
id = string
label = optional(string)
metric_stat = optional(object({
metric = object({
dimension = optional(list(object({
name = string
value = string
})))
metric_name = optional(string)
namespace = optional(string)
})
stat = string
unit = optional(string)
}))
return_data = optional(bool)
}))
}))
customized_scaling_metric_specification = optional(object({
metric_data_query = list(object({
expression = optional(string)
id = string
label = optional(string)
metric_stat = optional(object({
metric = object({
dimension = optional(list(object({
name = string
value = string
})))
metric_name = optional(string)
namespace = optional(string)
})
stat = string
unit = optional(string)
}))
return_data = optional(bool)
}))
}))
predefined_load_metric_specification = optional(object({
predefined_metric_type = string
resource_label = optional(string)
}))
predefined_metric_pair_specification = optional(object({
predefined_metric_type = string
resource_label = optional(string)
}))
predefined_scaling_metric_specification = optional(object({
predefined_metric_type = string
resource_label = optional(string)
}))
target_value = number
}))
mode = optional(string)
scheduling_buffer_time = optional(number)
}))
step_scaling_policy_configuration = optional(object({
adjustment_type = optional(string)
cooldown = optional(number)
metric_aggregation_type = optional(string)
min_adjustment_magnitude = optional(number)
step_adjustment = optional(list(object({
metric_interval_lower_bound = optional(string)
metric_interval_upper_bound = optional(string)
scaling_adjustment = number
})))
}))
target_tracking_scaling_policy_configuration = optional(object({
customized_metric_specification = optional(object({
dimensions = optional(list(object({
name = string
value = string
})))
metric_name = optional(string)
metrics = optional(list(object({
expression = optional(string)
id = string
label = optional(string)
metric_stat = optional(object({
metric = object({
dimensions = optional(list(object({
name = string
value = string
})))
metric_name = string
namespace = string
})
stat = string
unit = optional(string)
}))
return_data = optional(bool)
})))
namespace = optional(string)
statistic = optional(string)
unit = optional(string)
}))
disable_scale_in = optional(bool)
predefined_metric_specification = optional(object({
predefined_metric_type = string
resource_label = optional(string)
}))
scale_in_cooldown = optional(number, 300)
scale_out_cooldown = optional(number, 60)
target_value = optional(number, 75)
}))
}))
|
{
"cpu": {
"policy_type": "TargetTrackingScaling",
"target_tracking_scaling_policy_configuration": {
"predefined_metric_specification": {
"predefined_metric_type": "ECSServiceAverageCPUUtilization"
}
}
},
"memory": {
"policy_type": "TargetTrackingScaling",
"target_tracking_scaling_policy_configuration": {
"predefined_metric_specification": {
"predefined_metric_type": "ECSServiceAverageMemoryUtilization"
}
}
}
}
| no | +| [autoscaling\_scheduled\_actions](#input\_autoscaling\_scheduled\_actions) | Map of autoscaling scheduled actions to create for the service |
map(object({
name = optional(string)
min_capacity = number
max_capacity = number
schedule = string
start_time = optional(string)
end_time = optional(string)
timezone = optional(string)
}))
| `null` | no | +| [autoscaling\_suspended\_state](#input\_autoscaling\_suspended\_state) | Configuration block that specifies whether the scaling activities for the service are in a suspended state |
object({
dynamic_scaling_in_suspended = optional(bool, false)
dynamic_scaling_out_suspended = optional(bool, false)
scheduled_scaling_suspended = optional(bool, false)
})
| `null` | no | +| [availability\_zone\_rebalancing](#input\_availability\_zone\_rebalancing) | ECS automatically redistributes tasks within a service across Availability Zones (AZs) to mitigate the risk of impaired application availability due to underlying infrastructure failures and task lifecycle activities. The valid values are `ENABLED` and `DISABLED`. Defaults to `DISABLED` | `string` | `null` | no | | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 0fc4ded1..7cce6d25 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -5,4 +5,13 @@ module "ecs_service" { create = module.this.enabled name = module.this.name tags = module.this.tags + + alarms = var.alarms + assign_public_ip = var.assign_public_ip + autoscaling_max_capacity = var.autoscaling_max_capacity + autoscaling_min_capacity = var.autoscaling_min_capacity + autoscaling_policies = var.autoscaling_policies + autoscaling_scheduled_actions = var.autoscaling_scheduled_actions + autoscaling_suspended_state = var.autoscaling_suspended_state + availability_zone_rebalancing = var.availability_zone_rebalancing } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index c9fb5240..6a09ccee 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -1 +1,213 @@ -# DAVEH +variable "alarms" { + description = "Information about the CloudWatch alarms" + type = object({ + alarm_names = list(string) + enable = optional(bool, true) + rollback = optional(bool, true) + }) + default = null +} + +variable "assign_public_ip" { + description = "Assign a public IP address to the ENI (Fargate launch type only)" + type = bool + default = false +} + +variable "autoscaling_max_capacity" { + description = "Maximum number of tasks to run in your service" + type = number + default = 10 +} + +variable "autoscaling_min_capacity" { + description = "Minimum number of tasks to run in your service" + type = number + default = 1 +} + +variable "autoscaling_policies" { + description = "Map of autoscaling policies to create for the service" + type = map(object({ + name = optional(string) # Will fall back to the key name if not provided + policy_type = optional(string, "TargetTrackingScaling") + predictive_scaling_policy_configuration = optional(object({ + max_capacity_breach_behavior = optional(string) + max_capacity_buffer = optional(number) + metric_specification = list(object({ + customized_capacity_metric_specification = optional(object({ + metric_data_query = list(object({ + expression = optional(string) + id = string + label = optional(string) + metric_stat = optional(object({ + metric = object({ + dimension = optional(list(object({ + name = string + value = string + }))) + metric_name = optional(string) + namespace = optional(string) + }) + stat = string + unit = optional(string) + })) + return_data = optional(bool) + })) + })) + customized_load_metric_specification = optional(object({ + metric_data_query = list(object({ + expression = optional(string) + id = string + label = optional(string) + metric_stat = optional(object({ + metric = object({ + dimension = optional(list(object({ + name = string + value = string + }))) + metric_name = optional(string) + namespace = optional(string) + }) + stat = string + unit = optional(string) + })) + return_data = optional(bool) + })) + })) + customized_scaling_metric_specification = optional(object({ + metric_data_query = list(object({ + expression = optional(string) + id = string + label = optional(string) + metric_stat = optional(object({ + metric = object({ + dimension = optional(list(object({ + name = string + value = string + }))) + metric_name = optional(string) + namespace = optional(string) + }) + stat = string + unit = optional(string) + })) + return_data = optional(bool) + })) + })) + predefined_load_metric_specification = optional(object({ + predefined_metric_type = string + resource_label = optional(string) + })) + predefined_metric_pair_specification = optional(object({ + predefined_metric_type = string + resource_label = optional(string) + })) + predefined_scaling_metric_specification = optional(object({ + predefined_metric_type = string + resource_label = optional(string) + })) + target_value = number + })) + mode = optional(string) + scheduling_buffer_time = optional(number) + })) + step_scaling_policy_configuration = optional(object({ + adjustment_type = optional(string) + cooldown = optional(number) + metric_aggregation_type = optional(string) + min_adjustment_magnitude = optional(number) + step_adjustment = optional(list(object({ + metric_interval_lower_bound = optional(string) + metric_interval_upper_bound = optional(string) + scaling_adjustment = number + }))) + })) + target_tracking_scaling_policy_configuration = optional(object({ + customized_metric_specification = optional(object({ + dimensions = optional(list(object({ + name = string + value = string + }))) + metric_name = optional(string) + metrics = optional(list(object({ + expression = optional(string) + id = string + label = optional(string) + metric_stat = optional(object({ + metric = object({ + dimensions = optional(list(object({ + name = string + value = string + }))) + metric_name = string + namespace = string + }) + stat = string + unit = optional(string) + })) + return_data = optional(bool) + }))) + namespace = optional(string) + statistic = optional(string) + unit = optional(string) + })) + disable_scale_in = optional(bool) + predefined_metric_specification = optional(object({ + predefined_metric_type = string + resource_label = optional(string) + })) + scale_in_cooldown = optional(number, 300) + scale_out_cooldown = optional(number, 60) + target_value = optional(number, 75) + })) + })) + default = { + "cpu" : { + "policy_type" : "TargetTrackingScaling", + "target_tracking_scaling_policy_configuration" : { + "predefined_metric_specification" : { + "predefined_metric_type" : "ECSServiceAverageCPUUtilization" + } + } + }, + "memory" : { + "policy_type" : "TargetTrackingScaling", + "target_tracking_scaling_policy_configuration" : { + "predefined_metric_specification" : { + "predefined_metric_type" : "ECSServiceAverageMemoryUtilization" + } + } + } + } +} + +variable "autoscaling_scheduled_actions" { + description = "Map of autoscaling scheduled actions to create for the service" + type = map(object({ + name = optional(string) + min_capacity = number + max_capacity = number + schedule = string + start_time = optional(string) + end_time = optional(string) + timezone = optional(string) + })) + default = null +} + +variable "autoscaling_suspended_state" { + description = "Configuration block that specifies whether the scaling activities for the service are in a suspended state" + type = object({ + dynamic_scaling_in_suspended = optional(bool, false) + dynamic_scaling_out_suspended = optional(bool, false) + scheduled_scaling_suspended = optional(bool, false) + }) + default = null +} + +variable "availability_zone_rebalancing" { + description = "ECS automatically redistributes tasks within a service across Availability Zones (AZs) to mitigate the risk of impaired application availability due to underlying infrastructure failures and task lifecycle activities. The valid values are `ENABLED` and `DISABLED`. Defaults to `DISABLED`" + type = string + default = null +} From 7ca4f72dcb11b0680b706f23e7ba16e904466494 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 08:55:21 +0100 Subject: [PATCH 013/118] feat(ecs-service): add inputs ca-cp --- infrastructure/modules/ecs-service/README.md | 4 + infrastructure/modules/ecs-service/main.tf | 4 + .../modules/ecs-service/variables.tf | 165 ++++++++++++++++++ 3 files changed, 173 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 033cf591..49e58db7 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -41,7 +41,11 @@ No resources. | [autoscaling\_suspended\_state](#input\_autoscaling\_suspended\_state) | Configuration block that specifies whether the scaling activities for the service are in a suspended state |
object({
dynamic_scaling_in_suspended = optional(bool, false)
dynamic_scaling_out_suspended = optional(bool, false)
scheduled_scaling_suspended = optional(bool, false)
})
| `null` | no | | [availability\_zone\_rebalancing](#input\_availability\_zone\_rebalancing) | ECS automatically redistributes tasks within a service across Availability Zones (AZs) to mitigate the risk of impaired application availability due to underlying infrastructure failures and task lifecycle activities. The valid values are `ENABLED` and `DISABLED`. Defaults to `DISABLED` | `string` | `null` | no | | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [capacity\_provider\_strategy](#input\_capacity\_provider\_strategy) | Capacity provider strategies to use for the service. Can be one or more |
map(object({
base = optional(number)
capacity_provider = string
weight = optional(number)
}))
| `null` | no | +| [cluster\_arn](#input\_cluster\_arn) | ARN of the ECS cluster where the resources will be provisioned | `string` | `""` | no | +| [container\_definitions](#input\_container\_definitions) | A map of valid container definitions . Please note that you should only provide values that are part of the container definition document |
map(object({
create = optional(bool, true)
operating_system_family = optional(string)
tags = optional(map(string)) # Container definition
command = optional(list(string))
cpu = optional(number)
credentialSpecs = optional(list(string))
dependsOn = optional(list(object({
condition = string
containerName = string
})))
disableNetworking = optional(bool)
dnsSearchDomains = optional(list(string))
dnsServers = optional(list(string))
dockerLabels = optional(map(string))
dockerSecurityOptions = optional(list(string))
# DAVEH: following line was comment to preceeding line
enable_execute_command = optional(bool, false) # Set in standalone variable
entrypoint = optional(list(string))
environment = optional(list(object({
name = string
value = string
})))
environmentFiles = optional(list(object({
type = string
value = string
})))
essential = optional(bool)
extraHosts = optional(list(object({
hostname = string
ipAddress = string
})))
firelensConfiguration = optional(object({
options = optional(map(string))
type = optional(string)
}))
healthCheck = optional(object({
command = optional(list(string), [])
interval = optional(number, 30)
retries = optional(number, 3)
startPeriod = optional(number)
timeout = optional(number, 5)
}))
hostname = optional(string)
image = optional(string)
interactive = optional(bool)
links = optional(list(string))
linuxParameters = optional(object({
capabilities = optional(object({
add = optional(list(string))
drop = optional(list(string))
}))
devices = optional(list(object({
containerPath = optional(string)
hostPath = optional(string)
permissions = optional(list(string))
})))
initProcessEnabled = optional(bool)
maxSwap = optional(number)
sharedMemorySize = optional(number)
swappiness = optional(number)
tmpfs = optional(list(object({
containerPath = string
mountOptions = optional(list(string))
size = number
})))
}))
logConfiguration = optional(object({
logDriver = optional(string)
options = optional(map(string))
secretOptions = optional(list(object({
name = string
valueFrom = string
})))
}))
memory = optional(number)
memoryReservation = optional(number)
mountPoints = optional(list(object({
containerPath = optional(string)
readOnly = optional(bool)
sourceVolume = optional(string)
})))
name = optional(string)
portMappings = optional(list(object({
appProtocol = optional(string)
containerPort = optional(number)
containerPortRange = optional(string)
hostPort = optional(number)
name = optional(string)
protocol = optional(string)
})))
privileged = optional(bool)
pseudoTerminal = optional(bool)
readonlyRootFilesystem = optional(bool)
repositoryCredentials = optional(object({
credentialsParameter = optional(string)
}))
resourceRequirements = optional(list(object({
type = string
value = string
})))
restartPolicy = optional(object({
enabled = optional(bool)
ignoredExitCodes = optional(list(number))
restartAttemptPeriod = optional(number)
}))
secrets = optional(list(object({
name = string
valueFrom = string
})))
startTimeout = optional(number, 30)
stopTimeout = optional(number, 120)
systemControls = optional(list(object({
namespace = optional(string)
value = optional(string)
})))
ulimits = optional(list(object({
hardLimit = number
name = string
softLimit = number
})))
user = optional(string)
versionConsistency = optional(string)
volumesFrom = optional(list(object({
readOnly = optional(bool)
sourceContainer = optional(string)
})))
workingDirectory = optional(string)
# Cloudwatch Log Group
service = optional(string)
enable_cloudwatch_logging = optional(bool)
create_cloudwatch_log_group = optional(bool)
cloudwatch_log_group_name = optional(string)
cloudwatch_log_group_use_name_prefix = optional(bool)
cloudwatch_log_group_class = optional(string)
cloudwatch_log_group_retention_in_days = optional(number)
cloudwatch_log_group_kms_key_id = optional(string)
}))
| `{}` | 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 | +| [cpu](#input\_cpu) | Number of cpu units used by the task. If the `requires_compatibilities` is `FARGATE` this field is required | `number` | `1024` | no | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 7cce6d25..18ca2eb6 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -14,4 +14,8 @@ module "ecs_service" { autoscaling_scheduled_actions = var.autoscaling_scheduled_actions autoscaling_suspended_state = var.autoscaling_suspended_state availability_zone_rebalancing = var.availability_zone_rebalancing + capacity_provider_strategy = var.capacity_provider_strategy + cluster_arn = var.cluster_arn + container_definitions = var.container_definitions + cpu = var.cpu } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 6a09ccee..221859a5 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -211,3 +211,168 @@ variable "availability_zone_rebalancing" { type = string default = null } + +variable "capacity_provider_strategy" { + description = "Capacity provider strategies to use for the service. Can be one or more" + type = map(object({ + base = optional(number) + capacity_provider = string + weight = optional(number) + })) + default = null +} + +variable "cluster_arn" { + description = "ARN of the ECS cluster where the resources will be provisioned" + type = string + default = "" +} + +variable "container_definitions" { + description = "A map of valid container definitions . Please note that you should only provide values that are part of the container definition document" + type = map(object({ + create = optional(bool, true) + operating_system_family = optional(string) + tags = optional(map(string)) # Container definition + command = optional(list(string)) + cpu = optional(number) + credentialSpecs = optional(list(string)) + dependsOn = optional(list(object({ + condition = string + containerName = string + }))) + disableNetworking = optional(bool) + dnsSearchDomains = optional(list(string)) + dnsServers = optional(list(string)) + dockerLabels = optional(map(string)) + dockerSecurityOptions = optional(list(string)) + # DAVEH: following line was comment to preceeding line + enable_execute_command = optional(bool, false) # Set in standalone variable + entrypoint = optional(list(string)) + environment = optional(list(object({ + name = string + value = string + }))) + environmentFiles = optional(list(object({ + type = string + value = string + }))) + essential = optional(bool) + extraHosts = optional(list(object({ + hostname = string + ipAddress = string + }))) + firelensConfiguration = optional(object({ + options = optional(map(string)) + type = optional(string) + })) + healthCheck = optional(object({ + command = optional(list(string), []) + interval = optional(number, 30) + retries = optional(number, 3) + startPeriod = optional(number) + timeout = optional(number, 5) + })) + hostname = optional(string) + image = optional(string) + interactive = optional(bool) + links = optional(list(string)) + linuxParameters = optional(object({ + capabilities = optional(object({ + add = optional(list(string)) + drop = optional(list(string)) + })) + devices = optional(list(object({ + containerPath = optional(string) + hostPath = optional(string) + permissions = optional(list(string)) + }))) + initProcessEnabled = optional(bool) + maxSwap = optional(number) + sharedMemorySize = optional(number) + swappiness = optional(number) + tmpfs = optional(list(object({ + containerPath = string + mountOptions = optional(list(string)) + size = number + }))) + })) + logConfiguration = optional(object({ + logDriver = optional(string) + options = optional(map(string)) + secretOptions = optional(list(object({ + name = string + valueFrom = string + }))) + })) + memory = optional(number) + memoryReservation = optional(number) + mountPoints = optional(list(object({ + containerPath = optional(string) + readOnly = optional(bool) + sourceVolume = optional(string) + }))) + name = optional(string) + portMappings = optional(list(object({ + appProtocol = optional(string) + containerPort = optional(number) + containerPortRange = optional(string) + hostPort = optional(number) + name = optional(string) + protocol = optional(string) + }))) + privileged = optional(bool) + pseudoTerminal = optional(bool) + readonlyRootFilesystem = optional(bool) + repositoryCredentials = optional(object({ + credentialsParameter = optional(string) + })) + resourceRequirements = optional(list(object({ + type = string + value = string + }))) + restartPolicy = optional(object({ + enabled = optional(bool) + ignoredExitCodes = optional(list(number)) + restartAttemptPeriod = optional(number) + })) + secrets = optional(list(object({ + name = string + valueFrom = string + }))) + startTimeout = optional(number, 30) + stopTimeout = optional(number, 120) + systemControls = optional(list(object({ + namespace = optional(string) + value = optional(string) + }))) + ulimits = optional(list(object({ + hardLimit = number + name = string + softLimit = number + }))) + user = optional(string) + versionConsistency = optional(string) + volumesFrom = optional(list(object({ + readOnly = optional(bool) + sourceContainer = optional(string) + }))) + workingDirectory = optional(string) + # Cloudwatch Log Group + service = optional(string) + enable_cloudwatch_logging = optional(bool) + create_cloudwatch_log_group = optional(bool) + cloudwatch_log_group_name = optional(string) + cloudwatch_log_group_use_name_prefix = optional(bool) + cloudwatch_log_group_class = optional(string) + cloudwatch_log_group_retention_in_days = optional(number) + cloudwatch_log_group_kms_key_id = optional(string) + })) + default = {} +} + +variable "cpu" { + description = "Number of cpu units used by the task. If the `requires_compatibilities` is `FARGATE` this field is required" + type = number + default = 1024 +} From 05e5cb36eb9a431cbb4df6214e44242e53d098eb Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 09:09:18 +0100 Subject: [PATCH 014/118] feat(ecs-service): add inputs cr --- infrastructure/modules/ecs-service/README.md | 8 ++++ infrastructure/modules/ecs-service/main.tf | 32 ++++++++----- .../modules/ecs-service/variables.tf | 48 +++++++++++++++++++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 49e58db7..29e24624 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -46,6 +46,14 @@ No resources. | [container\_definitions](#input\_container\_definitions) | A map of valid container definitions . Please note that you should only provide values that are part of the container definition document |
map(object({
create = optional(bool, true)
operating_system_family = optional(string)
tags = optional(map(string)) # Container definition
command = optional(list(string))
cpu = optional(number)
credentialSpecs = optional(list(string))
dependsOn = optional(list(object({
condition = string
containerName = string
})))
disableNetworking = optional(bool)
dnsSearchDomains = optional(list(string))
dnsServers = optional(list(string))
dockerLabels = optional(map(string))
dockerSecurityOptions = optional(list(string))
# DAVEH: following line was comment to preceeding line
enable_execute_command = optional(bool, false) # Set in standalone variable
entrypoint = optional(list(string))
environment = optional(list(object({
name = string
value = string
})))
environmentFiles = optional(list(object({
type = string
value = string
})))
essential = optional(bool)
extraHosts = optional(list(object({
hostname = string
ipAddress = string
})))
firelensConfiguration = optional(object({
options = optional(map(string))
type = optional(string)
}))
healthCheck = optional(object({
command = optional(list(string), [])
interval = optional(number, 30)
retries = optional(number, 3)
startPeriod = optional(number)
timeout = optional(number, 5)
}))
hostname = optional(string)
image = optional(string)
interactive = optional(bool)
links = optional(list(string))
linuxParameters = optional(object({
capabilities = optional(object({
add = optional(list(string))
drop = optional(list(string))
}))
devices = optional(list(object({
containerPath = optional(string)
hostPath = optional(string)
permissions = optional(list(string))
})))
initProcessEnabled = optional(bool)
maxSwap = optional(number)
sharedMemorySize = optional(number)
swappiness = optional(number)
tmpfs = optional(list(object({
containerPath = string
mountOptions = optional(list(string))
size = number
})))
}))
logConfiguration = optional(object({
logDriver = optional(string)
options = optional(map(string))
secretOptions = optional(list(object({
name = string
valueFrom = string
})))
}))
memory = optional(number)
memoryReservation = optional(number)
mountPoints = optional(list(object({
containerPath = optional(string)
readOnly = optional(bool)
sourceVolume = optional(string)
})))
name = optional(string)
portMappings = optional(list(object({
appProtocol = optional(string)
containerPort = optional(number)
containerPortRange = optional(string)
hostPort = optional(number)
name = optional(string)
protocol = optional(string)
})))
privileged = optional(bool)
pseudoTerminal = optional(bool)
readonlyRootFilesystem = optional(bool)
repositoryCredentials = optional(object({
credentialsParameter = optional(string)
}))
resourceRequirements = optional(list(object({
type = string
value = string
})))
restartPolicy = optional(object({
enabled = optional(bool)
ignoredExitCodes = optional(list(number))
restartAttemptPeriod = optional(number)
}))
secrets = optional(list(object({
name = string
valueFrom = string
})))
startTimeout = optional(number, 30)
stopTimeout = optional(number, 120)
systemControls = optional(list(object({
namespace = optional(string)
value = optional(string)
})))
ulimits = optional(list(object({
hardLimit = number
name = string
softLimit = number
})))
user = optional(string)
versionConsistency = optional(string)
volumesFrom = optional(list(object({
readOnly = optional(bool)
sourceContainer = optional(string)
})))
workingDirectory = optional(string)
# Cloudwatch Log Group
service = optional(string)
enable_cloudwatch_logging = optional(bool)
create_cloudwatch_log_group = optional(bool)
cloudwatch_log_group_name = optional(string)
cloudwatch_log_group_use_name_prefix = optional(bool)
cloudwatch_log_group_class = optional(string)
cloudwatch_log_group_retention_in_days = optional(number)
cloudwatch_log_group_kms_key_id = optional(string)
}))
| `{}` | 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 | | [cpu](#input\_cpu) | Number of cpu units used by the task. If the `requires_compatibilities` is `FARGATE` this field is required | `number` | `1024` | no | +| [create\_iam\_role](#input\_create\_iam\_role) | Determines whether the ECS service IAM role should be created | `bool` | `true` | no | +| [create\_infrastructure\_iam\_role](#input\_create\_infrastructure\_iam\_role) | Determines whether the ECS infrastructure IAM role should be created | `bool` | `true` | no | +| [create\_security\_group](#input\_create\_security\_group) | Determines if a security group is created | `bool` | `true` | no | +| [create\_service](#input\_create\_service) | Determines whether service resource will be created (set to `false` in case you want to create task definition only) | `bool` | `true` | no | +| [create\_task\_definition](#input\_create\_task\_definition) | Determines whether to create a task definition or use existing/provided | `bool` | `true` | no | +| [create\_task\_exec\_iam\_role](#input\_create\_task\_exec\_iam\_role) | Determines whether the ECS task definition IAM role should be created | `bool` | `true` | no | +| [create\_task\_exec\_policy](#input\_create\_task\_exec\_policy) | Determines whether the ECS task definition IAM policy should be created. This includes permissions included in AmazonECSTaskExecutionRolePolicy as well as access to secrets and SSM parameters | `bool` | `true` | no | +| [create\_tasks\_iam\_role](#input\_create\_tasks\_iam\_role) | Determines whether the ECS tasks IAM role should be created | `bool` | `true` | no | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 18ca2eb6..1ca8f043 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -6,16 +6,24 @@ module "ecs_service" { name = module.this.name tags = module.this.tags - alarms = var.alarms - assign_public_ip = var.assign_public_ip - autoscaling_max_capacity = var.autoscaling_max_capacity - autoscaling_min_capacity = var.autoscaling_min_capacity - autoscaling_policies = var.autoscaling_policies - autoscaling_scheduled_actions = var.autoscaling_scheduled_actions - autoscaling_suspended_state = var.autoscaling_suspended_state - availability_zone_rebalancing = var.availability_zone_rebalancing - capacity_provider_strategy = var.capacity_provider_strategy - cluster_arn = var.cluster_arn - container_definitions = var.container_definitions - cpu = var.cpu + alarms = var.alarms + assign_public_ip = var.assign_public_ip + autoscaling_max_capacity = var.autoscaling_max_capacity + autoscaling_min_capacity = var.autoscaling_min_capacity + autoscaling_policies = var.autoscaling_policies + autoscaling_scheduled_actions = var.autoscaling_scheduled_actions + autoscaling_suspended_state = var.autoscaling_suspended_state + availability_zone_rebalancing = var.availability_zone_rebalancing + capacity_provider_strategy = var.capacity_provider_strategy + cluster_arn = var.cluster_arn + container_definitions = var.container_definitions + cpu = var.cpu + create_iam_role = var.create_iam_role + create_infrastructure_iam_role = var.create_infrastructure_iam_role + create_security_group = var.create_security_group + create_service = var.create_service + create_task_definition = var.create_task_definition + create_task_exec_iam_role = var.create_task_exec_iam_role + create_task_exec_policy = var.create_task_exec_policy + create_tasks_iam_role = var.create_tasks_iam_role } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 221859a5..c0f8a902 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -376,3 +376,51 @@ variable "cpu" { type = number default = 1024 } + +variable "create_iam_role" { + description = "Determines whether the ECS service IAM role should be created" + type = bool + default = true +} + +variable "create_infrastructure_iam_role" { + description = "Determines whether the ECS infrastructure IAM role should be created" + type = bool + default = true +} + +variable "create_security_group" { + description = "Determines if a security group is created" + type = bool + default = true +} + +variable "create_service" { + description = "Determines whether service resource will be created (set to `false` in case you want to create task definition only)" + type = bool + default = true +} + +variable "create_task_definition" { + description = "Determines whether to create a task definition or use existing/provided" + type = bool + default = true +} + +variable "create_task_exec_iam_role" { + description = "Determines whether the ECS task definition IAM role should be created" + type = bool + default = true +} + +variable "create_task_exec_policy" { + description = "Determines whether the ECS task definition IAM policy should be created. This includes permissions included in AmazonECSTaskExecutionRolePolicy as well as access to secrets and SSM parameters" + type = bool + default = true +} + +variable "create_tasks_iam_role" { + description = "Determines whether the ECS tasks IAM role should be created" + type = bool + default = true +} From 97c23b9ba5f978d3fbcd387f6437d327c392a65f Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 09:22:29 +0100 Subject: [PATCH 015/118] feat(ecs-service): add inputs d --- infrastructure/modules/ecs-service/README.md | 6 ++ infrastructure/modules/ecs-service/main.tf | 46 ++++++++------- .../modules/ecs-service/variables.tf | 58 +++++++++++++++++++ 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 29e24624..c66e08f8 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -57,7 +57,13 @@ No resources. | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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\_circuit\_breaker](#input\_deployment\_circuit\_breaker) | Configuration block for deployment circuit breaker |
object({
enable = bool
rollback = bool
})
| `null` | no | +| [deployment\_configuration](#input\_deployment\_configuration) | Configuration block for deployment settings |
object({
strategy = optional(string)
bake_time_in_minutes = optional(string)
canary_configuration = optional(object({
canary_bake_time_in_minutes = optional(string)
canary_percent = optional(string)
}))
linear_configuration = optional(object({
step_bake_time_in_minutes = optional(string)
step_percent = optional(string)
}))
lifecycle_hook = optional(map(object({
hook_target_arn = string
role_arn = optional(string)
lifecycle_stages = list(string)
hook_details = optional(string)
})))
})
| `null` | no | +| [deployment\_controller](#input\_deployment\_controller) | Configuration block for deployment controller configuration |
object({
type = optional(string)
})
| `null` | no | +| [deployment\_maximum\_percent](#input\_deployment\_maximum\_percent) | Upper limit (as a percentage of the service's `desired_count`) of the number of running tasks that can be running in a service during a deployment | `number` | `200` | no | +| [deployment\_minimum\_healthy\_percent](#input\_deployment\_minimum\_healthy\_percent) | Lower limit (as a percentage of the service's `desired_count`) of the number of running tasks that must remain running and healthy in a service during a deployment | `number` | `66` | 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 | +| [desired\_count](#input\_desired\_count) | Number of instances of the task definition to place and keep running | `number` | `1` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 1ca8f043..921b7137 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -6,24 +6,30 @@ module "ecs_service" { name = module.this.name tags = module.this.tags - alarms = var.alarms - assign_public_ip = var.assign_public_ip - autoscaling_max_capacity = var.autoscaling_max_capacity - autoscaling_min_capacity = var.autoscaling_min_capacity - autoscaling_policies = var.autoscaling_policies - autoscaling_scheduled_actions = var.autoscaling_scheduled_actions - autoscaling_suspended_state = var.autoscaling_suspended_state - availability_zone_rebalancing = var.availability_zone_rebalancing - capacity_provider_strategy = var.capacity_provider_strategy - cluster_arn = var.cluster_arn - container_definitions = var.container_definitions - cpu = var.cpu - create_iam_role = var.create_iam_role - create_infrastructure_iam_role = var.create_infrastructure_iam_role - create_security_group = var.create_security_group - create_service = var.create_service - create_task_definition = var.create_task_definition - create_task_exec_iam_role = var.create_task_exec_iam_role - create_task_exec_policy = var.create_task_exec_policy - create_tasks_iam_role = var.create_tasks_iam_role + alarms = var.alarms + assign_public_ip = var.assign_public_ip + autoscaling_max_capacity = var.autoscaling_max_capacity + autoscaling_min_capacity = var.autoscaling_min_capacity + autoscaling_policies = var.autoscaling_policies + autoscaling_scheduled_actions = var.autoscaling_scheduled_actions + autoscaling_suspended_state = var.autoscaling_suspended_state + availability_zone_rebalancing = var.availability_zone_rebalancing + capacity_provider_strategy = var.capacity_provider_strategy + cluster_arn = var.cluster_arn + container_definitions = var.container_definitions + cpu = var.cpu + create_iam_role = var.create_iam_role + create_infrastructure_iam_role = var.create_infrastructure_iam_role + create_security_group = var.create_security_group + create_service = var.create_service + create_task_definition = var.create_task_definition + create_task_exec_iam_role = var.create_task_exec_iam_role + create_task_exec_policy = var.create_task_exec_policy + create_tasks_iam_role = var.create_tasks_iam_role + deployment_circuit_breaker = var.deployment_circuit_breaker + deployment_configuration = var.deployment_configuration + deployment_controller = var.deployment_controller + deployment_maximum_percent = var.deployment_maximum_percent + deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent + desired_count = var.desired_count } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index c0f8a902..82f48e7d 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -424,3 +424,61 @@ variable "create_tasks_iam_role" { type = bool default = true } + +variable "deployment_circuit_breaker" { + description = "Configuration block for deployment circuit breaker" + type = object({ + enable = bool + rollback = bool + }) + default = null +} + +variable "deployment_configuration" { + description = "Configuration block for deployment settings" + type = object({ + strategy = optional(string) + bake_time_in_minutes = optional(string) + canary_configuration = optional(object({ + canary_bake_time_in_minutes = optional(string) + canary_percent = optional(string) + })) + linear_configuration = optional(object({ + step_bake_time_in_minutes = optional(string) + step_percent = optional(string) + })) + lifecycle_hook = optional(map(object({ + hook_target_arn = string + role_arn = optional(string) + lifecycle_stages = list(string) + hook_details = optional(string) + }))) + }) + default = null +} + +variable "deployment_controller" { + description = "Configuration block for deployment controller configuration" + type = object({ + type = optional(string) + }) + default = null +} + +variable "deployment_maximum_percent" { + description = "Upper limit (as a percentage of the service's `desired_count`) of the number of running tasks that can be running in a service during a deployment" + type = number + default = 200 +} + +variable "deployment_minimum_healthy_percent" { + description = "Lower limit (as a percentage of the service's `desired_count`) of the number of running tasks that must remain running and healthy in a service during a deployment" + type = number + default = 66 +} + +variable "desired_count" { + description = "Number of instances of the task definition to place and keep running" + type = number + default = 1 +} From 528506b81c929e82cd0676cfc6bee7a38b800d9b Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:10:05 +0100 Subject: [PATCH 016/118] feat(ecs-service): add inputs e --- infrastructure/modules/ecs-service/README.md | 6 +++ infrastructure/modules/ecs-service/main.tf | 6 +++ .../modules/ecs-service/variables.tf | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index c66e08f8..9ed3b128 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -64,8 +64,14 @@ No resources. | [deployment\_minimum\_healthy\_percent](#input\_deployment\_minimum\_healthy\_percent) | Lower limit (as a percentage of the service's `desired_count`) of the number of running tasks that must remain running and healthy in a service during a deployment | `number` | `66` | 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 | | [desired\_count](#input\_desired\_count) | Number of instances of the task definition to place and keep running | `number` | `1` | no | +| [enable\_autoscaling](#input\_enable\_autoscaling) | Determines whether to enable autoscaling for the service | `bool` | `true` | no | +| [enable\_ecs\_managed\_tags](#input\_enable\_ecs\_managed\_tags) | Specifies whether to enable Amazon ECS managed tags for the tasks within the service | `bool` | `true` | no | +| [enable\_execute\_command](#input\_enable\_execute\_command) | Specifies whether to enable Amazon ECS Exec for the tasks within the service | `bool` | `false` | no | +| [enable\_fault\_injection](#input\_enable\_fault\_injection) | Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false` | `bool` | `null` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [ephemeral\_storage](#input\_ephemeral\_storage) | The amount of ephemeral storage to allocate for the task. This parameter is used to expand the total amount of ephemeral storage available, beyond the default amount, for tasks hosted on AWS Fargate |
object({
size_in_gib = number
})
| `null` | no | +| [external\_id](#input\_external\_id) | The external ID associated with the task set | `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 | | [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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 921b7137..11d211b2 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -32,4 +32,10 @@ module "ecs_service" { deployment_maximum_percent = var.deployment_maximum_percent deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent desired_count = var.desired_count + enable_autoscaling = var.enable_autoscaling + enable_ecs_managed_tags = var.enable_ecs_managed_tags + enable_execute_command = var.enable_execute_command + enable_fault_injection = var.enable_fault_injection + ephemeral_storage = var.ephemeral_storage + external_id = var.external_id } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 82f48e7d..0c96885a 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -482,3 +482,41 @@ variable "desired_count" { type = number default = 1 } + +variable "enable_autoscaling" { + description = "Determines whether to enable autoscaling for the service" + type = bool + default = true +} + +variable "enable_ecs_managed_tags" { + description = "Specifies whether to enable Amazon ECS managed tags for the tasks within the service" + type = bool + default = true +} + +variable "enable_execute_command" { + description = "Specifies whether to enable Amazon ECS Exec for the tasks within the service" + type = bool + default = false +} + +variable "enable_fault_injection" { + description = "Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false`" + type = bool + default = null +} + +variable "ephemeral_storage" { + description = "The amount of ephemeral storage to allocate for the task. This parameter is used to expand the total amount of ephemeral storage available, beyond the default amount, for tasks hosted on AWS Fargate" + type = object({ + size_in_gib = number + }) + default = null +} + +variable "external_id" { + description = "The external ID associated with the task set" + type = string + default = null +} From dd3e1e28df418783530fee99668938915ff070bd Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:15:15 +0100 Subject: [PATCH 017/118] feat(ecs-service): add inputs f-h --- infrastructure/modules/ecs-service/README.md | 3 +++ infrastructure/modules/ecs-service/main.tf | 3 +++ .../modules/ecs-service/variables.tf | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 9ed3b128..cd203a68 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -72,6 +72,9 @@ No resources. | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | | [ephemeral\_storage](#input\_ephemeral\_storage) | The amount of ephemeral storage to allocate for the task. This parameter is used to expand the total amount of ephemeral storage available, beyond the default amount, for tasks hosted on AWS Fargate |
object({
size_in_gib = number
})
| `null` | no | | [external\_id](#input\_external\_id) | The external ID associated with the task set | `string` | `null` | no | +| [force\_delete](#input\_force\_delete) | Enable to delete a service even if it wasn't scaled down to zero tasks. It's only necessary to use this if the service uses the `REPLICA` scheduling strategy | `bool` | `null` | no | +| [force\_new\_deployment](#input\_force\_new\_deployment) | Enable to force a new task deployment of the service. This can be used to update tasks to use a newer Docker image with same image/tag combination, roll Fargate tasks onto a newer platform version, or immediately deploy `ordered_placement_strategy` and `placement_constraints` updates | `bool` | `true` | no | +| [health\_check\_grace\_period\_seconds](#input\_health\_check\_grace\_period\_seconds) | Seconds to ignore failing load balancer health checks on newly instantiated tasks to prevent premature shutdown, up to 2147483647. Only valid for services configured to use load balancers | `number` | `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 | | [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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 11d211b2..13b535f2 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -38,4 +38,7 @@ module "ecs_service" { enable_fault_injection = var.enable_fault_injection ephemeral_storage = var.ephemeral_storage external_id = var.external_id + force_delete = var.force_delete + force_new_deployment = var.force_new_deployment + health_check_grace_period_seconds = var.health_check_grace_period_seconds } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 0c96885a..1032fcfa 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -520,3 +520,21 @@ variable "external_id" { type = string default = null } + +variable "force_delete" { + description = "Enable to delete a service even if it wasn't scaled down to zero tasks. It's only necessary to use this if the service uses the `REPLICA` scheduling strategy" + type = bool + default = null +} + +variable "force_new_deployment" { + description = "Enable to force a new task deployment of the service. This can be used to update tasks to use a newer Docker image with same image/tag combination, roll Fargate tasks onto a newer platform version, or immediately deploy `ordered_placement_strategy` and `placement_constraints` updates" + type = bool + default = true +} + +variable "health_check_grace_period_seconds" { + description = "Seconds to ignore failing load balancer health checks on newly instantiated tasks to prevent premature shutdown, up to 2147483647. Only valid for services configured to use load balancers" + type = number + default = null +} From 6d11d4369e3ab42db13c82a2ec09a41b3e8195a5 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:24:48 +0100 Subject: [PATCH 018/118] feat(ecs-service): add inputs ia --- infrastructure/modules/ecs-service/README.md | 8 +++ infrastructure/modules/ecs-service/main.tf | 8 +++ .../modules/ecs-service/variables.tf | 68 +++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index cd203a68..118f9d3d 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -75,6 +75,14 @@ No resources. | [force\_delete](#input\_force\_delete) | Enable to delete a service even if it wasn't scaled down to zero tasks. It's only necessary to use this if the service uses the `REPLICA` scheduling strategy | `bool` | `null` | no | | [force\_new\_deployment](#input\_force\_new\_deployment) | Enable to force a new task deployment of the service. This can be used to update tasks to use a newer Docker image with same image/tag combination, roll Fargate tasks onto a newer platform version, or immediately deploy `ordered_placement_strategy` and `placement_constraints` updates | `bool` | `true` | no | | [health\_check\_grace\_period\_seconds](#input\_health\_check\_grace\_period\_seconds) | Seconds to ignore failing load balancer health checks on newly instantiated tasks to prevent premature shutdown, up to 2147483647. Only valid for services configured to use load balancers | `number` | `null` | no | +| [iam\_role\_arn](#input\_iam\_role\_arn) | Existing IAM role ARN | `string` | `null` | no | +| [iam\_role\_description](#input\_iam\_role\_description) | Description of the role | `string` | `null` | no | +| [iam\_role\_name](#input\_iam\_role\_name) | Name to use on IAM role created | `string` | `null` | no | +| [iam\_role\_path](#input\_iam\_role\_path) | IAM role path | `string` | `null` | no | +| [iam\_role\_permissions\_boundary](#input\_iam\_role\_permissions\_boundary) | ARN of the policy that is used to set the permissions boundary for the IAM role | `string` | `null` | no | +| [iam\_role\_statements](#input\_iam\_role\_statements) | A map of IAM policy statements for custom permission usage |
list(object({
sid = optional(string)
actions = optional(list(string))
not_actions = optional(list(string))
effect = optional(string)
resources = optional(list(string))
not_resources = optional(list(string))
principals = optional(list(object({
type = string
identifiers = list(string)
})))
not_principals = optional(list(object({
type = string
identifiers = list(string)
})))
condition = optional(list(object({
test = string
values = list(string)
variable = string
})))
}))
| `null` | no | +| [iam\_role\_tags](#input\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | +| [iam\_role\_use\_name\_prefix](#input\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`iam_role_name`) is used as a prefix | `bool` | `true` | 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 | | [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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 13b535f2..b3ad4100 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -41,4 +41,12 @@ module "ecs_service" { force_delete = var.force_delete force_new_deployment = var.force_new_deployment health_check_grace_period_seconds = var.health_check_grace_period_seconds + iam_role_arn = var.iam_role_arn + iam_role_description = var.iam_role_description + iam_role_name = var.iam_role_name + iam_role_path = var.iam_role_path + iam_role_permissions_boundary = var.iam_role_permissions_boundary + iam_role_statements = var.iam_role_statements + iam_role_tags = var.iam_role_tags + iam_role_use_name_prefix = var.iam_role_use_name_prefix } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 1032fcfa..42c40a0f 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -538,3 +538,71 @@ variable "health_check_grace_period_seconds" { type = number default = null } + +variable "iam_role_arn" { + description = "Existing IAM role ARN" + type = string + default = null +} + +variable "iam_role_description" { + description = "Description of the role" + type = string + default = null +} + +variable "iam_role_name" { + description = "Name to use on IAM role created" + type = string + default = null +} + +variable "iam_role_path" { + description = "IAM role path" + type = string + default = null +} + +variable "iam_role_permissions_boundary" { + description = "ARN of the policy that is used to set the permissions boundary for the IAM role" + type = string + default = null +} + +variable "iam_role_statements" { + description = "A map of IAM policy statements for custom permission usage" + type = list(object({ + sid = optional(string) + actions = optional(list(string)) + not_actions = optional(list(string)) + effect = optional(string) + resources = optional(list(string)) + not_resources = optional(list(string)) + principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + not_principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + condition = optional(list(object({ + test = string + values = list(string) + variable = string + }))) + })) + default = null +} + +variable "iam_role_tags" { + description = "A map of additional tags to add to the IAM role created" + type = map(string) + default = {} +} + +variable "iam_role_use_name_prefix" { + description = "Determines whether the IAM role name (`iam_role_name`) is used as a prefix" + type = bool + default = true +} From e642161068c7429bb2bca92b06fe2b32907b7fa1 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:31:24 +0100 Subject: [PATCH 019/118] feat(ecs-service): add inputs ig-ip --- infrastructure/modules/ecs-service/README.md | 9 ++ infrastructure/modules/ecs-service/main.tf | 95 ++++++++++--------- .../modules/ecs-service/variables.tf | 54 +++++++++++ 3 files changed, 115 insertions(+), 43 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 118f9d3d..cc60883b 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -84,6 +84,15 @@ No resources. | [iam\_role\_tags](#input\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | | [iam\_role\_use\_name\_prefix](#input\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`iam_role_name`) is used as a prefix | `bool` | `true` | 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 | +| [ignore\_task\_definition\_changes](#input\_ignore\_task\_definition\_changes) | Whether changes to service `task_definition` changes should be ignored | `bool` | `false` | no | +| [infrastructure\_iam\_role\_arn](#input\_infrastructure\_iam\_role\_arn) | Existing IAM role ARN | `string` | `null` | no | +| [infrastructure\_iam\_role\_description](#input\_infrastructure\_iam\_role\_description) | Description of the role | `string` | `null` | no | +| [infrastructure\_iam\_role\_name](#input\_infrastructure\_iam\_role\_name) | Name to use on IAM role created | `string` | `null` | no | +| [infrastructure\_iam\_role\_path](#input\_infrastructure\_iam\_role\_path) | IAM role path | `string` | `null` | no | +| [infrastructure\_iam\_role\_permissions\_boundary](#input\_infrastructure\_iam\_role\_permissions\_boundary) | ARN of the policy that is used to set the permissions boundary for the IAM role | `string` | `null` | no | +| [infrastructure\_iam\_role\_tags](#input\_infrastructure\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | +| [infrastructure\_iam\_role\_use\_name\_prefix](#input\_infrastructure\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`iam_role_name`) is used as a prefix | `bool` | `true` | no | +| [ipc\_mode](#input\_ipc\_mode) | IPC resource namespace to be used for the containers in the task The valid values are `host`, `task`, and `none` | `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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index b3ad4100..ffeb7d57 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -6,47 +6,56 @@ module "ecs_service" { name = module.this.name tags = module.this.tags - alarms = var.alarms - assign_public_ip = var.assign_public_ip - autoscaling_max_capacity = var.autoscaling_max_capacity - autoscaling_min_capacity = var.autoscaling_min_capacity - autoscaling_policies = var.autoscaling_policies - autoscaling_scheduled_actions = var.autoscaling_scheduled_actions - autoscaling_suspended_state = var.autoscaling_suspended_state - availability_zone_rebalancing = var.availability_zone_rebalancing - capacity_provider_strategy = var.capacity_provider_strategy - cluster_arn = var.cluster_arn - container_definitions = var.container_definitions - cpu = var.cpu - create_iam_role = var.create_iam_role - create_infrastructure_iam_role = var.create_infrastructure_iam_role - create_security_group = var.create_security_group - create_service = var.create_service - create_task_definition = var.create_task_definition - create_task_exec_iam_role = var.create_task_exec_iam_role - create_task_exec_policy = var.create_task_exec_policy - create_tasks_iam_role = var.create_tasks_iam_role - deployment_circuit_breaker = var.deployment_circuit_breaker - deployment_configuration = var.deployment_configuration - deployment_controller = var.deployment_controller - deployment_maximum_percent = var.deployment_maximum_percent - deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent - desired_count = var.desired_count - enable_autoscaling = var.enable_autoscaling - enable_ecs_managed_tags = var.enable_ecs_managed_tags - enable_execute_command = var.enable_execute_command - enable_fault_injection = var.enable_fault_injection - ephemeral_storage = var.ephemeral_storage - external_id = var.external_id - force_delete = var.force_delete - force_new_deployment = var.force_new_deployment - health_check_grace_period_seconds = var.health_check_grace_period_seconds - iam_role_arn = var.iam_role_arn - iam_role_description = var.iam_role_description - iam_role_name = var.iam_role_name - iam_role_path = var.iam_role_path - iam_role_permissions_boundary = var.iam_role_permissions_boundary - iam_role_statements = var.iam_role_statements - iam_role_tags = var.iam_role_tags - iam_role_use_name_prefix = var.iam_role_use_name_prefix + alarms = var.alarms + assign_public_ip = var.assign_public_ip + autoscaling_max_capacity = var.autoscaling_max_capacity + autoscaling_min_capacity = var.autoscaling_min_capacity + autoscaling_policies = var.autoscaling_policies + autoscaling_scheduled_actions = var.autoscaling_scheduled_actions + autoscaling_suspended_state = var.autoscaling_suspended_state + availability_zone_rebalancing = var.availability_zone_rebalancing + capacity_provider_strategy = var.capacity_provider_strategy + cluster_arn = var.cluster_arn + container_definitions = var.container_definitions + cpu = var.cpu + create_iam_role = var.create_iam_role + create_infrastructure_iam_role = var.create_infrastructure_iam_role + create_security_group = var.create_security_group + create_service = var.create_service + create_task_definition = var.create_task_definition + create_task_exec_iam_role = var.create_task_exec_iam_role + create_task_exec_policy = var.create_task_exec_policy + create_tasks_iam_role = var.create_tasks_iam_role + deployment_circuit_breaker = var.deployment_circuit_breaker + deployment_configuration = var.deployment_configuration + deployment_controller = var.deployment_controller + deployment_maximum_percent = var.deployment_maximum_percent + deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent + desired_count = var.desired_count + enable_autoscaling = var.enable_autoscaling + enable_ecs_managed_tags = var.enable_ecs_managed_tags + enable_execute_command = var.enable_execute_command + enable_fault_injection = var.enable_fault_injection + ephemeral_storage = var.ephemeral_storage + external_id = var.external_id + force_delete = var.force_delete + force_new_deployment = var.force_new_deployment + health_check_grace_period_seconds = var.health_check_grace_period_seconds + iam_role_arn = var.iam_role_arn + iam_role_description = var.iam_role_description + iam_role_name = var.iam_role_name + iam_role_path = var.iam_role_path + iam_role_permissions_boundary = var.iam_role_permissions_boundary + iam_role_statements = var.iam_role_statements + iam_role_tags = var.iam_role_tags + iam_role_use_name_prefix = var.iam_role_use_name_prefix + ignore_task_definition_changes = var.ignore_task_definition_changes + infrastructure_iam_role_arn = var.infrastructure_iam_role_arn + infrastructure_iam_role_description = var.infrastructure_iam_role_description + infrastructure_iam_role_name = var.infrastructure_iam_role_name + infrastructure_iam_role_path = var.infrastructure_iam_role_path + infrastructure_iam_role_permissions_boundary = var.infrastructure_iam_role_permissions_boundary + infrastructure_iam_role_tags = var.infrastructure_iam_role_tags + infrastructure_iam_role_use_name_prefix = var.infrastructure_iam_role_use_name_prefix + ipc_mode = var.ipc_mode } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 42c40a0f..bd633040 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -606,3 +606,57 @@ variable "iam_role_use_name_prefix" { type = bool default = true } + +variable "ignore_task_definition_changes" { + description = "Whether changes to service `task_definition` changes should be ignored" + type = bool + default = false +} + +variable "infrastructure_iam_role_arn" { + description = "Existing IAM role ARN" + type = string + default = null +} + +variable "infrastructure_iam_role_description" { + description = "Description of the role" + type = string + default = null +} + +variable "infrastructure_iam_role_name" { + description = "Name to use on IAM role created" + type = string + default = null +} + +variable "infrastructure_iam_role_path" { + description = "IAM role path" + type = string + default = null +} + +variable "infrastructure_iam_role_permissions_boundary" { + description = "ARN of the policy that is used to set the permissions boundary for the IAM role" + type = string + default = null +} + +variable "infrastructure_iam_role_tags" { + description = "A map of additional tags to add to the IAM role created" + type = map(string) + default = {} +} + +variable "infrastructure_iam_role_use_name_prefix" { + description = "Determines whether the IAM role name (`iam_role_name`) is used as a prefix" + type = bool + default = true +} + +variable "ipc_mode" { + description = "IPC resource namespace to be used for the containers in the task The valid values are `host`, `task`, and `none`" + type = string + default = null +} From 024c0c2f94d5a117170b819af3bcd5e41c2e13ad Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:51:38 +0100 Subject: [PATCH 020/118] feat(ecs-service): add inputs l-p --- infrastructure/modules/ecs-service/README.md | 10 +++ infrastructure/modules/ecs-service/main.tf | 10 +++ .../modules/ecs-service/variables.tf | 81 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index cc60883b..fe921665 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -97,10 +97,20 @@ No resources. | [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 | +| [launch\_type](#input\_launch\_type) | Launch type on which to run your service. The valid values are `EC2`, `FARGATE`, and `EXTERNAL`. Defaults to `FARGATE` | `string` | `"FARGATE"` | no | +| [load\_balancer](#input\_load\_balancer) | Configuration block for load balancers |
map(object({
container_name = string
container_port = number
elb_name = optional(string)
target_group_arn = optional(string)
advanced_configuration = optional(object({
alternate_target_group_arn = string
production_listener_rule = string # Should be optional but bug in provider
role_arn = optional(string)
test_listener_rule = optional(string)
}))
}))
| `null` | no | +| [memory](#input\_memory) | Amount of memory (in MiB) used by the task. If the `requires_compatibilities` is `FARGATE` this field is required | `number` | `2048` | 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 | +| [network\_mode](#input\_network\_mode) | Docker networking mode to use for the containers in the task. Valid values are `none`, `bridge`, `awsvpc`, and `host` | `string` | `"awsvpc"` | no | | [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [ordered\_placement\_strategy](#input\_ordered\_placement\_strategy) | Service level strategy rules that are taken into consideration during task placement. List from top to bottom in order of precedence |
list(object({
field = optional(string)
type = string
}))
| `null` | no | | [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [pid\_mode](#input\_pid\_mode) | Process namespace to use for the containers in the task. The valid values are `host` and `task` | `string` | `null` | no | +| [placement\_constraints](#input\_placement\_constraints) | Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the service, see `task_definition_placement_constraints` for setting at the task definition |
map(object({
expression = optional(string)
type = string
}))
| `null` | no | +| [platform\_version](#input\_platform\_version) | Platform version on which to run your service. Only applicable for `launch_type` set to `FARGATE`. Defaults to `LATEST` | `string` | `null` | 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 | +| [propagate\_tags](#input\_propagate\_tags) | Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION` | `string` | `null` | no | +| [proxy\_configuration](#input\_proxy\_configuration) | Configuration block for the App Mesh proxy |
object({
container_name = string
properties = optional(map(string))
type = optional(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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index ffeb7d57..ca8c8a3f 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -58,4 +58,14 @@ module "ecs_service" { infrastructure_iam_role_tags = var.infrastructure_iam_role_tags infrastructure_iam_role_use_name_prefix = var.infrastructure_iam_role_use_name_prefix ipc_mode = var.ipc_mode + launch_type = var.launch_type + load_balancer = var.load_balancer + memory = var.memory + network_mode = var.network_mode + ordered_placement_strategy = var.ordered_placement_strategy + pid_mode = var.pid_mode + placement_constraints = var.placement_constraints + platform_version = var.platform_version + propagate_tags = var.propagate_tags + proxy_configuration = var.proxy_configuration } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index bd633040..adaab9f7 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -660,3 +660,84 @@ variable "ipc_mode" { type = string default = null } + +variable "launch_type" { + description = "Launch type on which to run your service. The valid values are `EC2`, `FARGATE`, and `EXTERNAL`. Defaults to `FARGATE`" + type = string + default = "FARGATE" +} + +variable "load_balancer" { + description = "Configuration block for load balancers" + type = map(object({ + container_name = string + container_port = number + elb_name = optional(string) + target_group_arn = optional(string) + advanced_configuration = optional(object({ + alternate_target_group_arn = string + production_listener_rule = string # Should be optional but bug in provider + role_arn = optional(string) + test_listener_rule = optional(string) + })) + })) + default = null +} + +variable "memory" { + description = "Amount of memory (in MiB) used by the task. If the `requires_compatibilities` is `FARGATE` this field is required" + type = number + default = 2048 +} + +variable "network_mode" { + description = "Docker networking mode to use for the containers in the task. Valid values are `none`, `bridge`, `awsvpc`, and `host`" + type = string + default = "awsvpc" +} + +variable "ordered_placement_strategy" { + description = "Service level strategy rules that are taken into consideration during task placement. List from top to bottom in order of precedence" + type = list(object({ + field = optional(string) + type = string + })) + default = null +} + +variable "pid_mode" { + description = "Process namespace to use for the containers in the task. The valid values are `host` and `task`" + type = string + default = null +} + +variable "placement_constraints" { + description = "Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the service, see `task_definition_placement_constraints` for setting at the task definition" + type = map(object({ + expression = optional(string) + type = string + })) + default = null +} + +variable "platform_version" { + description = "Platform version on which to run your service. Only applicable for `launch_type` set to `FARGATE`. Defaults to `LATEST`" + type = string + default = null +} + +variable "propagate_tags" { + description = "Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION`" + type = string + default = null +} + +variable "proxy_configuration" { + description = "Configuration block for the App Mesh proxy" + type = object({ + container_name = string + properties = optional(map(string)) + type = optional(string) + }) + default = null +} From f6e524cca1c70270552056ca93d19ac63e3d4286 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:58:15 +0100 Subject: [PATCH 021/118] feat(ecs-service): add inputs r-sc --- infrastructure/modules/ecs-service/README.md | 4 +++ infrastructure/modules/ecs-service/main.tf | 4 +++ .../modules/ecs-service/variables.tf | 30 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index fe921665..167e2dde 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -114,6 +114,10 @@ No resources. | [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 | +| [requires\_compatibilities](#input\_requires\_compatibilities) | Set of launch types required by the task. The valid values are `EC2`, `FARGATE`, `EXTERNAL`, and `MANAGED_INSTANCES` | `list(string)` |
[
"FARGATE"
]
| no | +| [runtime\_platform](#input\_runtime\_platform) | Configuration block for `runtime_platform` that containers in your task may use |
object({
cpu_architecture = optional(string, "X86_64")
operating_system_family = optional(string, "LINUX")
})
| `{}` | no | +| [scale](#input\_scale) | A floating-point percentage of the desired number of tasks to place and keep running in the task set |
object({
unit = optional(string)
value = optional(number)
})
| `null` | no | +| [scheduling\_strategy](#input\_scheduling\_strategy) | Scheduling strategy to use for the service. The valid values are `REPLICA` and `DAEMON`. Defaults to `REPLICA` | `string` | `null` | 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 | | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index ca8c8a3f..5d51a407 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -68,4 +68,8 @@ module "ecs_service" { platform_version = var.platform_version propagate_tags = var.propagate_tags proxy_configuration = var.proxy_configuration + requires_compatibilities = var.requires_compatibilities + runtime_platform = var.runtime_platform + scale = var.scale + scheduling_strategy = var.scheduling_strategy } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index adaab9f7..9d694057 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -741,3 +741,33 @@ variable "proxy_configuration" { }) default = null } + +variable "requires_compatibilities" { + description = "Set of launch types required by the task. The valid values are `EC2`, `FARGATE`, `EXTERNAL`, and `MANAGED_INSTANCES`" + type = list(string) + default = ["FARGATE"] +} + +variable "runtime_platform" { + description = "Configuration block for `runtime_platform` that containers in your task may use" + type = object({ + cpu_architecture = optional(string, "X86_64") + operating_system_family = optional(string, "LINUX") + }) + default = {} +} + +variable "scale" { + description = "A floating-point percentage of the desired number of tasks to place and keep running in the task set" + type = object({ + unit = optional(string) + value = optional(number) + }) + default = null +} + +variable "scheduling_strategy" { + description = "Scheduling strategy to use for the service. The valid values are `REPLICA` and `DAEMON`. Defaults to `REPLICA`" + type = string + default = null +} From c643a1677d60b023c5b2d31ba7e36ca39ea2f0e0 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 11:56:42 +0100 Subject: [PATCH 022/118] feat(ecs-service): add inputs s --- infrastructure/modules/ecs-service/README.md | 13 ++ infrastructure/modules/ecs-service/main.tf | 13 ++ .../modules/ecs-service/variables.tf | 148 ++++++++++++++++++ 3 files changed, 174 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 167e2dde..1b3b70ad 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -118,9 +118,22 @@ No resources. | [runtime\_platform](#input\_runtime\_platform) | Configuration block for `runtime_platform` that containers in your task may use |
object({
cpu_architecture = optional(string, "X86_64")
operating_system_family = optional(string, "LINUX")
})
| `{}` | no | | [scale](#input\_scale) | A floating-point percentage of the desired number of tasks to place and keep running in the task set |
object({
unit = optional(string)
value = optional(number)
})
| `null` | no | | [scheduling\_strategy](#input\_scheduling\_strategy) | Scheduling strategy to use for the service. The valid values are `REPLICA` and `DAEMON`. Defaults to `REPLICA` | `string` | `null` | no | +| [security\_group\_description](#input\_security\_group\_description) | Description of the security group created | `string` | `null` | no | +| [security\_group\_egress\_rules](#input\_security\_group\_egress\_rules) | Security group egress rules to add to the security group created |
map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
from_port = optional(string)
ip_protocol = optional(string, "tcp")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
to_port = optional(string)
}))
| `{}` | no | +| [security\_group\_ids](#input\_security\_group\_ids) | List of security groups to associate with the task or service | `list(string)` | `[]` | no | +| [security\_group\_ingress\_rules](#input\_security\_group\_ingress\_rules) | Security group ingress rules to add to the security group created |
map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
from_port = optional(string)
ip_protocol = optional(string, "tcp")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
to_port = optional(string)
}))
| `{}` | no | +| [security\_group\_name](#input\_security\_group\_name) | Name to use on security group created | `string` | `null` | no | +| [security\_group\_tags](#input\_security\_group\_tags) | A map of additional tags to add to the security group created | `map(string)` | `{}` | no | +| [security\_group\_use\_name\_prefix](#input\_security\_group\_use\_name\_prefix) | Determines whether the security group name (`security_group_name`) is used as a prefix | `bool` | `true` | 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 | +| [service\_connect\_configuration](#input\_service\_connect\_configuration) | The ECS Service Connect configuration for this service to discover and connect to services, and be discovered by, and connected from, other services within a namespace |
object({
enabled = optional(bool, true)
access_log_configuration = optional(object({
format = string
include_query_parameters = optional(string)
}))
log_configuration = optional(object({
log_driver = string
options = optional(map(string))
secret_option = optional(list(object({
name = string
value_from = string
})))
}))
namespace = optional(string)
service = optional(list(object({
client_alias = optional(object({
dns_name = optional(string)
port = number
test_traffic_rules = optional(list(object({
header = optional(object({
name = string
value = object({
exact = string
})
}))
})))
}))
discovery_name = optional(string)
ingress_port_override = optional(number)
port_name = string
timeout = optional(object({
idle_timeout_seconds = optional(number)
per_request_timeout_seconds = optional(number)
}))
tls = optional(object({
issuer_cert_authority = object({
aws_pca_authority_arn = string
})
kms_key = optional(string)
role_arn = optional(string)
}))
})))
})
| `null` | no | +| [service\_registries](#input\_service\_registries) | Service discovery registries for the service |
object({
container_name = optional(string)
container_port = optional(number)
port = optional(number)
registry_arn = string
})
| `null` | no | +| [service\_tags](#input\_service\_tags) | A map of additional tags to add to the service | `map(string)` | `{}` | no | +| [sigint\_rollback](#input\_sigint\_rollback) | Whether to enable graceful termination of deployments using SIGINT signals. Only applicable when using ECS deployment controller and requires wait\_for\_steady\_state = true. Default is false | `bool` | `null` | no | +| [skip\_destroy](#input\_skip\_destroy) | If true, the task is not deleted when the service is deleted | `bool` | `null` | 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 subnets to associate with the task or service | `list(string)` | `[]` | no | | [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 this module path. | `string` | `null` | no | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 5d51a407..6d69af18 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -72,4 +72,17 @@ module "ecs_service" { runtime_platform = var.runtime_platform scale = var.scale scheduling_strategy = var.scheduling_strategy + security_group_description = var.security_group_description + security_group_egress_rules = var.security_group_egress_rules + security_group_ids = var.security_group_ids + security_group_ingress_rules = var.security_group_ingress_rules + security_group_name = var.security_group_name + security_group_tags = var.security_group_tags + security_group_use_name_prefix = var.security_group_use_name_prefix + service_connect_configuration = var.service_connect_configuration + service_registries = var.service_registries + service_tags = var.service_tags + sigint_rollback = var.sigint_rollback + skip_destroy = var.skip_destroy + subnet_ids = var.subnet_ids } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 9d694057..f048b8f5 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -771,3 +771,151 @@ variable "scheduling_strategy" { type = string default = null } + +variable "security_group_description" { + description = "Description of the security group created" + type = string + default = null +} + +variable "security_group_egress_rules" { + description = "Security group egress rules to add to the security group created" + type = map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + from_port = optional(string) + ip_protocol = optional(string, "tcp") + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + to_port = optional(string) + })) + default = {} +} + +variable "security_group_ids" { + description = "List of security groups to associate with the task or service" + type = list(string) + default = [] +} + +variable "security_group_ingress_rules" { + description = "Security group ingress rules to add to the security group created" + type = map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + from_port = optional(string) + ip_protocol = optional(string, "tcp") + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + to_port = optional(string) + })) + default = {} +} + +variable "security_group_name" { + description = "Name to use on security group created" + type = string + default = null +} + +variable "security_group_tags" { + description = "A map of additional tags to add to the security group created" + type = map(string) + default = {} +} + +variable "security_group_use_name_prefix" { + description = "Determines whether the security group name (`security_group_name`) is used as a prefix" + type = bool + default = true +} + +variable "service_connect_configuration" { + description = "The ECS Service Connect configuration for this service to discover and connect to services, and be discovered by, and connected from, other services within a namespace" + type = object({ + enabled = optional(bool, true) + access_log_configuration = optional(object({ + format = string + include_query_parameters = optional(string) + })) + log_configuration = optional(object({ + log_driver = string + options = optional(map(string)) + secret_option = optional(list(object({ + name = string + value_from = string + }))) + })) + namespace = optional(string) + service = optional(list(object({ + client_alias = optional(object({ + dns_name = optional(string) + port = number + test_traffic_rules = optional(list(object({ + header = optional(object({ + name = string + value = object({ + exact = string + }) + })) + }))) + })) + discovery_name = optional(string) + ingress_port_override = optional(number) + port_name = string + timeout = optional(object({ + idle_timeout_seconds = optional(number) + per_request_timeout_seconds = optional(number) + })) + tls = optional(object({ + issuer_cert_authority = object({ + aws_pca_authority_arn = string + }) + kms_key = optional(string) + role_arn = optional(string) + })) + }))) + }) + default = null +} + +variable "service_registries" { + description = "Service discovery registries for the service" + type = object({ + container_name = optional(string) + container_port = optional(number) + port = optional(number) + registry_arn = string + }) + default = null +} + +variable "service_tags" { + description = "A map of additional tags to add to the service" + type = map(string) + default = {} +} + +variable "sigint_rollback" { + description = "Whether to enable graceful termination of deployments using SIGINT signals. Only applicable when using ECS deployment controller and requires wait_for_steady_state = true. Default is false" + type = bool + default = null +} + +variable "skip_destroy" { + description = "If true, the task is not deleted when the service is deleted" + type = bool + default = null +} + +variable "subnet_ids" { + description = "List of subnets to associate with the task or service" + type = list(string) + default = [] +} From a81de960ad3969aaf32427b6099e8d6d286136c5 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:11:27 +0100 Subject: [PATCH 023/118] feat(ecs-service): add inputs task_* --- infrastructure/modules/ecs-service/README.md | 16 +++ infrastructure/modules/ecs-service/main.tf | 17 +++ .../modules/ecs-service/variables.tf | 119 ++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 1b3b70ad..5602a126 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -136,6 +136,22 @@ No resources. | [subnet\_ids](#input\_subnet\_ids) | List of subnets to associate with the task or service | `list(string)` | `[]` | no | | [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 | +| [task\_definition\_arn](#input\_task\_definition\_arn) | Existing task definition ARN. Required when `create_task_definition` is `false` | `string` | `null` | no | +| [task\_definition\_placement\_constraints](#input\_task\_definition\_placement\_constraints) | Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the task definition, see `placement_constraints` for setting at the service |
map(object({
expression = optional(string)
type = string
}))
| `null` | no | +| [task\_exec\_iam\_policy\_path](#input\_task\_exec\_iam\_policy\_path) | Path for the iam role | `string` | `null` | no | +| [task\_exec\_iam\_role\_arn](#input\_task\_exec\_iam\_role\_arn) | Existing IAM role ARN | `string` | `null` | no | +| [task\_exec\_iam\_role\_description](#input\_task\_exec\_iam\_role\_description) | Description of the role | `string` | `null` | no | +| [task\_exec\_iam\_role\_max\_session\_duration](#input\_task\_exec\_iam\_role\_max\_session\_duration) | Maximum session duration (in seconds) for ECS task execution role. Default is 3600. | `number` | `null` | no | +| [task\_exec\_iam\_role\_name](#input\_task\_exec\_iam\_role\_name) | Name to use on IAM role created | `string` | `null` | no | +| [task\_exec\_iam\_role\_path](#input\_task\_exec\_iam\_role\_path) | IAM role path | `string` | `null` | no | +| [task\_exec\_iam\_role\_permissions\_boundary](#input\_task\_exec\_iam\_role\_permissions\_boundary) | ARN of the policy that is used to set the permissions boundary for the IAM role | `string` | `null` | no | +| [task\_exec\_iam\_role\_policies](#input\_task\_exec\_iam\_role\_policies) | Map of IAM role policy ARNs to attach to the IAM role | `map(string)` | `null` | no | +| [task\_exec\_iam\_role\_tags](#input\_task\_exec\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | +| [task\_exec\_iam\_role\_use\_name\_prefix](#input\_task\_exec\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`task_exec_iam_role_name`) is used as a prefix | `bool` | `true` | no | +| [task\_exec\_iam\_statements](#input\_task\_exec\_iam\_statements) | A map of IAM policy statements for custom permission usage |
list(object({
sid = optional(string)
actions = optional(list(string))
not_actions = optional(list(string))
effect = optional(string)
resources = optional(list(string))
not_resources = optional(list(string))
principals = optional(list(object({
type = string
identifiers = list(string)
})))
not_principals = optional(list(object({
type = string
identifiers = list(string)
})))
condition = optional(list(object({
test = string
values = list(string)
variable = string
})))
}))
| `null` | no | +| [task\_exec\_secret\_arns](#input\_task\_exec\_secret\_arns) | List of SecretsManager secret ARNs the task execution role will be permitted to get/read | `list(string)` | `[]` | no | +| [task\_exec\_ssm\_param\_arns](#input\_task\_exec\_ssm\_param\_arns) | List of SSM parameter ARNs the task execution role will be permitted to get/read | `list(string)` | `[]` | no | +| [task\_tags](#input\_task\_tags) | A map of additional tags to add to the task definition/set created | `map(string)` | `{}` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | 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/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 6d69af18..9cfb37fd 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -85,4 +85,21 @@ module "ecs_service" { sigint_rollback = var.sigint_rollback skip_destroy = var.skip_destroy subnet_ids = var.subnet_ids + task_definition_arn = var.task_definition_arn + task_definition_placement_constraints = var.task_definition_placement_constraints + task_exec_iam_policy_path = var.task_exec_iam_policy_path + task_exec_iam_role_arn = var.task_exec_iam_role_arn + task_exec_iam_role_description = var.task_exec_iam_role_description + task_exec_iam_role_max_session_duration = var.task_exec_iam_role_max_session_duration + task_exec_iam_role_name = var.task_exec_iam_role_name + task_exec_iam_role_path = var.task_exec_iam_role_path + task_exec_iam_role_permissions_boundary = var.task_exec_iam_role_permissions_boundary + task_exec_iam_role_policies = var.task_exec_iam_role_policies + task_exec_iam_role_tags = var.task_exec_iam_role_tags + task_exec_iam_role_use_name_prefix = var.task_exec_iam_role_use_name_prefix + task_exec_iam_statements = var.task_exec_iam_statements + task_exec_secret_arns = var.task_exec_secret_arns + task_exec_ssm_param_arns = var.task_exec_ssm_param_arns + task_tags = var.task_tags + } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index f048b8f5..b85608fc 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -919,3 +919,122 @@ variable "subnet_ids" { type = list(string) default = [] } + +variable "task_definition_arn" { + description = "Existing task definition ARN. Required when `create_task_definition` is `false`" + type = string + default = null +} + +variable "task_definition_placement_constraints" { + description = "Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the task definition, see `placement_constraints` for setting at the service" + type = map(object({ + expression = optional(string) + type = string + })) + default = null +} + +variable "task_exec_iam_policy_path" { + description = "Path for the iam role" + type = string + default = null +} + +variable "task_exec_iam_role_arn" { + description = "Existing IAM role ARN" + type = string + default = null +} + +variable "task_exec_iam_role_description" { + description = "Description of the role" + type = string + default = null +} + +variable "task_exec_iam_role_max_session_duration" { + description = "Maximum session duration (in seconds) for ECS task execution role. Default is 3600." + type = number + default = null +} + +variable "task_exec_iam_role_name" { + description = "Name to use on IAM role created" + type = string + default = null +} + +variable "task_exec_iam_role_path" { + description = "IAM role path" + type = string + default = null +} + +variable "task_exec_iam_role_permissions_boundary" { + description = "ARN of the policy that is used to set the permissions boundary for the IAM role" + type = string + default = null +} + +variable "task_exec_iam_role_policies" { + description = "Map of IAM role policy ARNs to attach to the IAM role" + type = map(string) + default = null +} + +variable "task_exec_iam_role_tags" { + description = "A map of additional tags to add to the IAM role created" + type = map(string) + default = {} +} + +variable "task_exec_iam_role_use_name_prefix" { + description = "Determines whether the IAM role name (`task_exec_iam_role_name`) is used as a prefix" + type = bool + default = true +} + +variable "task_exec_iam_statements" { + description = "A map of IAM policy statements for custom permission usage" + type = list(object({ + sid = optional(string) + actions = optional(list(string)) + not_actions = optional(list(string)) + effect = optional(string) + resources = optional(list(string)) + not_resources = optional(list(string)) + principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + not_principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + condition = optional(list(object({ + test = string + values = list(string) + variable = string + }))) + })) + default = null +} + +variable "task_exec_secret_arns" { + description = "List of SecretsManager secret ARNs the task execution role will be permitted to get/read" + type = list(string) + default = [] +} + +variable "task_exec_ssm_param_arns" { + description = "List of SSM parameter ARNs the task execution role will be permitted to get/read" + type = list(string) + default = [] +} + +variable "task_tags" { + description = "A map of additional tags to add to the task definition/set created" + type = map(string) + default = {} +} From d531b247dcd3c37923085893ed97e76102580999 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:26:51 +0100 Subject: [PATCH 024/118] feat(ecs-service): add inputs tasks_* --- infrastructure/modules/ecs-service/README.md | 10 +++ infrastructure/modules/ecs-service/main.tf | 11 ++- .../modules/ecs-service/variables.tf | 80 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 5602a126..3c7f87f9 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -152,6 +152,16 @@ No resources. | [task\_exec\_secret\_arns](#input\_task\_exec\_secret\_arns) | List of SecretsManager secret ARNs the task execution role will be permitted to get/read | `list(string)` | `[]` | no | | [task\_exec\_ssm\_param\_arns](#input\_task\_exec\_ssm\_param\_arns) | List of SSM parameter ARNs the task execution role will be permitted to get/read | `list(string)` | `[]` | no | | [task\_tags](#input\_task\_tags) | A map of additional tags to add to the task definition/set created | `map(string)` | `{}` | no | +| [tasks\_iam\_role\_arn](#input\_tasks\_iam\_role\_arn) | Existing IAM role ARN | `string` | `null` | no | +| [tasks\_iam\_role\_description](#input\_tasks\_iam\_role\_description) | Description of the role | `string` | `null` | no | +| [tasks\_iam\_role\_max\_session\_duration](#input\_tasks\_iam\_role\_max\_session\_duration) | Maximum session duration (in seconds) for ECS tasks role. Default is 3600. | `number` | `null` | no | +| [tasks\_iam\_role\_name](#input\_tasks\_iam\_role\_name) | Name to use on IAM role created | `string` | `null` | no | +| [tasks\_iam\_role\_path](#input\_tasks\_iam\_role\_path) | IAM role path | `string` | `null` | no | +| [tasks\_iam\_role\_permissions\_boundary](#input\_tasks\_iam\_role\_permissions\_boundary) | ARN of the policy that is used to set the permissions boundary for the IAM role | `string` | `null` | no | +| [tasks\_iam\_role\_policies](#input\_tasks\_iam\_role\_policies) | Map of additional IAM role policy ARNs to attach to the IAM role | `map(string)` | `null` | no | +| [tasks\_iam\_role\_statements](#input\_tasks\_iam\_role\_statements) | A map of IAM policy statements for custom permission usage |
list(object({
sid = optional(string)
actions = optional(list(string))
not_actions = optional(list(string))
effect = optional(string)
resources = optional(list(string))
not_resources = optional(list(string))
principals = optional(list(object({
type = string
identifiers = list(string)
})))
not_principals = optional(list(object({
type = string
identifiers = list(string)
})))
condition = optional(list(object({
test = string
values = list(string)
variable = string
})))
}))
| `null` | no | +| [tasks\_iam\_role\_tags](#input\_tasks\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | +| [tasks\_iam\_role\_use\_name\_prefix](#input\_tasks\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`tasks_iam_role_name`) is used as a prefix | `bool` | `true` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | 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/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 9cfb37fd..0fd21b71 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -101,5 +101,14 @@ module "ecs_service" { task_exec_secret_arns = var.task_exec_secret_arns task_exec_ssm_param_arns = var.task_exec_ssm_param_arns task_tags = var.task_tags - + tasks_iam_role_arn = var.tasks_iam_role_arn + tasks_iam_role_description = var.tasks_iam_role_description + tasks_iam_role_max_session_duration = var.tasks_iam_role_max_session_duration + tasks_iam_role_name = var.tasks_iam_role_name + tasks_iam_role_path = var.tasks_iam_role_path + tasks_iam_role_permissions_boundary = var.tasks_iam_role_permissions_boundary + tasks_iam_role_policies = var.tasks_iam_role_policies + tasks_iam_role_statements = var.tasks_iam_role_statements + tasks_iam_role_tags = var.tasks_iam_role_tags + tasks_iam_role_use_name_prefix = var.tasks_iam_role_use_name_prefix } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index b85608fc..2007b9aa 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -1038,3 +1038,83 @@ variable "task_tags" { type = map(string) default = {} } + +variable "tasks_iam_role_arn" { + description = "Existing IAM role ARN" + type = string + default = null +} + +variable "tasks_iam_role_description" { + description = "Description of the role" + type = string + default = null +} + +variable "tasks_iam_role_max_session_duration" { + description = "Maximum session duration (in seconds) for ECS tasks role. Default is 3600." + type = number + default = null +} + +variable "tasks_iam_role_name" { + description = "Name to use on IAM role created" + type = string + default = null +} + +variable "tasks_iam_role_path" { + description = "IAM role path" + type = string + default = null +} + +variable "tasks_iam_role_permissions_boundary" { + description = "ARN of the policy that is used to set the permissions boundary for the IAM role" + type = string + default = null +} + +variable "tasks_iam_role_policies" { + description = "Map of additional IAM role policy ARNs to attach to the IAM role" + type = map(string) + default = null +} + +variable "tasks_iam_role_statements" { + description = "A map of IAM policy statements for custom permission usage" + type = list(object({ + sid = optional(string) + actions = optional(list(string)) + not_actions = optional(list(string)) + effect = optional(string) + resources = optional(list(string)) + not_resources = optional(list(string)) + principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + not_principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + condition = optional(list(object({ + test = string + values = list(string) + variable = string + }))) + })) + default = null +} + +variable "tasks_iam_role_tags" { + description = "A map of additional tags to add to the IAM role created" + type = map(string) + default = {} +} + +variable "tasks_iam_role_use_name_prefix" { + description = "Determines whether the IAM role name (`tasks_iam_role_name`) is used as a prefix" + type = bool + default = true +} From a60ab2a7c1e876e31513c9582f58ea7eba234826 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:34:04 +0100 Subject: [PATCH 025/118] feat(ecs-service): add inputs ti-z --- infrastructure/modules/ecs-service/README.md | 10 ++ infrastructure/modules/ecs-service/main.tf | 10 ++ .../modules/ecs-service/variables.tf | 115 ++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 3c7f87f9..7a8e2a20 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -163,7 +163,17 @@ No resources. | [tasks\_iam\_role\_tags](#input\_tasks\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | | [tasks\_iam\_role\_use\_name\_prefix](#input\_tasks\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`tasks_iam_role_name`) is used as a prefix | `bool` | `true` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [timeouts](#input\_timeouts) | Create, update, and delete timeout configurations for the service |
object({
create = optional(string)
delete = optional(string)
update = optional(string)
})
| `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [track\_latest](#input\_track\_latest) | Whether should track latest `ACTIVE` task definition on AWS or the one created with the resource stored in state. Useful in the event the task definition is modified outside of this resource | `bool` | `true` | no | +| [triggers](#input\_triggers) | Map of arbitrary keys and values that, when changed, will trigger an in-place update (redeployment). Useful with `timestamp()` | `map(string)` | `null` | no | +| [volume](#input\_volume) | Configuration block for volumes that containers in your task may use |
map(object({
configure_at_launch = optional(bool)
docker_volume_configuration = optional(object({
autoprovision = optional(bool)
driver = optional(string)
driver_opts = optional(map(string))
labels = optional(map(string))
scope = optional(string)
}))
efs_volume_configuration = optional(object({
authorization_config = optional(object({
access_point_id = optional(string)
iam = optional(string)
}))
file_system_id = string
root_directory = optional(string)
transit_encryption = optional(string)
transit_encryption_port = optional(number)
}))
fsx_windows_file_server_volume_configuration = optional(object({
authorization_config = optional(object({
credentials_parameter = string
domain = string
}))
file_system_id = string
root_directory = string
}))
host_path = optional(string)
name = optional(string)
}))
| `null` | no | +| [volume\_configuration](#input\_volume\_configuration) | Configuration for a volume specified in the task definition as a volume that is configured at launch time |
object({
name = string
managed_ebs_volume = object({
encrypted = optional(bool)
file_system_type = optional(string)
iops = optional(number)
kms_key_id = optional(string)
size_in_gb = optional(number)
snapshot_id = optional(string)
tag_specifications = optional(list(object({
propagate_tags = optional(string, "TASK_DEFINITION")
resource_type = string
tags = optional(map(string))
})))
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_type = optional(string)
})
})
| `null` | no | +| [vpc\_id](#input\_vpc\_id) | The VPC ID where to deploy the task or service. If not provided, the VPC ID is derived from the subnets provided | `string` | `null` | no | +| [vpc\_lattice\_configurations](#input\_vpc\_lattice\_configurations) | The VPC Lattice configuration for your service that allows Lattice to connect, secure, and monitor your service across multiple accounts and VPCs |
object({
role_arn = string
target_group_arn = string
port_name = string
})
| `null` | no | +| [wait\_for\_steady\_state](#input\_wait\_for\_steady\_state) | If true, Terraform will wait for the service to reach a steady state before continuing. Default is `false` | `bool` | `null` | no | +| [wait\_until\_stable](#input\_wait\_until\_stable) | Whether terraform should wait until the task set has reached `STEADY_STATE` | `bool` | `null` | no | +| [wait\_until\_stable\_timeout](#input\_wait\_until\_stable\_timeout) | Wait timeout for task set to reach `STEADY_STATE`. Valid time units include `ns`, `us` (or µs), `ms`, `s`, `m`, and `h`. Default `10m` | `string` | `null` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | ## Outputs diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 0fd21b71..9f98e545 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -111,4 +111,14 @@ module "ecs_service" { tasks_iam_role_statements = var.tasks_iam_role_statements tasks_iam_role_tags = var.tasks_iam_role_tags tasks_iam_role_use_name_prefix = var.tasks_iam_role_use_name_prefix + timeouts = var.timeouts + track_latest = var.track_latest + triggers = var.triggers + volume = var.volume + volume_configuration = var.volume_configuration + vpc_id = var.vpc_id + vpc_lattice_configurations = var.vpc_lattice_configurations + wait_for_steady_state = var.wait_for_steady_state + wait_until_stable = var.wait_until_stable + wait_until_stable_timeout = var.wait_until_stable_timeout } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 2007b9aa..78fc6bbc 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -1118,3 +1118,118 @@ variable "tasks_iam_role_use_name_prefix" { type = bool default = true } + +variable "timeouts" { + description = "Create, update, and delete timeout configurations for the service" + type = object({ + create = optional(string) + delete = optional(string) + update = optional(string) + }) + default = null +} + +variable "track_latest" { + description = "Whether should track latest `ACTIVE` task definition on AWS or the one created with the resource stored in state. Useful in the event the task definition is modified outside of this resource" + type = bool + default = true +} + +variable "triggers" { + description = "Map of arbitrary keys and values that, when changed, will trigger an in-place update (redeployment). Useful with `timestamp()`" + type = map(string) + default = null +} + +variable "volume" { + description = "Configuration block for volumes that containers in your task may use" + type = map(object({ + configure_at_launch = optional(bool) + docker_volume_configuration = optional(object({ + autoprovision = optional(bool) + driver = optional(string) + driver_opts = optional(map(string)) + labels = optional(map(string)) + scope = optional(string) + })) + efs_volume_configuration = optional(object({ + authorization_config = optional(object({ + access_point_id = optional(string) + iam = optional(string) + })) + file_system_id = string + root_directory = optional(string) + transit_encryption = optional(string) + transit_encryption_port = optional(number) + })) + fsx_windows_file_server_volume_configuration = optional(object({ + authorization_config = optional(object({ + credentials_parameter = string + domain = string + })) + file_system_id = string + root_directory = string + })) + host_path = optional(string) + name = optional(string) + })) + default = null +} + +variable "volume_configuration" { + description = "Configuration for a volume specified in the task definition as a volume that is configured at launch time" + type = object({ + name = string + managed_ebs_volume = object({ + encrypted = optional(bool) + file_system_type = optional(string) + iops = optional(number) + kms_key_id = optional(string) + size_in_gb = optional(number) + snapshot_id = optional(string) + tag_specifications = optional(list(object({ + propagate_tags = optional(string, "TASK_DEFINITION") + resource_type = string + tags = optional(map(string)) + }))) + throughput = optional(number) + volume_initialization_rate = optional(number) + volume_type = optional(string) + }) + }) + default = null +} + +variable "vpc_id" { + description = "The VPC ID where to deploy the task or service. If not provided, the VPC ID is derived from the subnets provided" + type = string + default = null +} + +variable "vpc_lattice_configurations" { + description = "The VPC Lattice configuration for your service that allows Lattice to connect, secure, and monitor your service across multiple accounts and VPCs" + type = object({ + role_arn = string + target_group_arn = string + port_name = string + }) + default = null +} + +variable "wait_for_steady_state" { + description = "If true, Terraform will wait for the service to reach a steady state before continuing. Default is `false`" + type = bool + default = null +} + +variable "wait_until_stable" { + description = "Whether terraform should wait until the task set has reached `STEADY_STATE`" + type = bool + default = null +} + +variable "wait_until_stable_timeout" { + description = "Wait timeout for task set to reach `STEADY_STATE`. Valid time units include `ns`, `us` (or µs), `ms`, `s`, `m`, and `h`. Default `10m`" + type = string + default = null +} From 9545a7a7a2ded86ec2be1e0419ddd40cf89f5b64 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:45:19 +0100 Subject: [PATCH 026/118] feat(ecs-service): add outputs --- infrastructure/modules/ecs-service/README.md | 28 +++- infrastructure/modules/ecs-service/outputs.tf | 125 +++++++++++++++++- 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 7a8e2a20..d8895c6e 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -178,7 +178,33 @@ No resources. ## Outputs -No outputs. +| Name | Description | +| ---- | ----------- | +| [autoscaling\_policies](#output\_autoscaling\_policies) | Map of autoscaling policies and their attributes | +| [autoscaling\_scheduled\_actions](#output\_autoscaling\_scheduled\_actions) | Map of autoscaling scheduled actions and their attributes | +| [container\_definitions](#output\_container\_definitions) | Container definitions | +| [ecs\_service\_id](#output\_ecs\_service\_id) | ARN that identifies the service | +| [ecs\_service\_name](#output\_ecs\_service\_name) | Name of the service | +| [iam\_role\_arn](#output\_iam\_role\_arn) | Service IAM role ARN | +| [iam\_role\_name](#output\_iam\_role\_name) | Service IAM role name | +| [iam\_role\_unique\_id](#output\_iam\_role\_unique\_id) | Stable and unique string identifying the service IAM role | +| [infrastructure\_iam\_role\_arn](#output\_infrastructure\_iam\_role\_arn) | Infrastructure IAM role ARN | +| [infrastructure\_iam\_role\_name](#output\_infrastructure\_iam\_role\_name) | Infrastructure IAM role name | +| [security\_group\_arn](#output\_security\_group\_arn) | ARN of the security group | +| [security\_group\_id](#output\_security\_group\_id) | ID of the security group | +| [task\_definition\_arn](#output\_task\_definition\_arn) | Full ARN of the Task Definition (including both `family` and `revision`) | +| [task\_definition\_family](#output\_task\_definition\_family) | The unique name of the task definition | +| [task\_definition\_revision](#output\_task\_definition\_revision) | Revision of the task in a particular family | +| [task\_exec\_iam\_role\_arn](#output\_task\_exec\_iam\_role\_arn) | Task execution IAM role ARN | +| [task\_exec\_iam\_role\_name](#output\_task\_exec\_iam\_role\_name) | Task execution IAM role name | +| [task\_exec\_iam\_role\_unique\_id](#output\_task\_exec\_iam\_role\_unique\_id) | Stable and unique string identifying the task execution IAM role | +| [task\_set\_arn](#output\_task\_set\_arn) | The ARN that identifies the task set | +| [task\_set\_id](#output\_task\_set\_id) | The ID of the task set | +| [task\_set\_stability\_status](#output\_task\_set\_stability\_status) | The stability status. This indicates whether the task set has reached a steady state | +| [task\_set\_status](#output\_task\_set\_status) | The status of the task set | +| [tasks\_iam\_role\_arn](#output\_tasks\_iam\_role\_arn) | Tasks IAM role ARN | +| [tasks\_iam\_role\_name](#output\_tasks\_iam\_role\_name) | Tasks IAM role name | +| [tasks\_iam\_role\_unique\_id](#output\_tasks\_iam\_role\_unique\_id) | Stable and unique string identifying the tasks IAM role | diff --git a/infrastructure/modules/ecs-service/outputs.tf b/infrastructure/modules/ecs-service/outputs.tf index c9fb5240..45595c45 100644 --- a/infrastructure/modules/ecs-service/outputs.tf +++ b/infrastructure/modules/ecs-service/outputs.tf @@ -1 +1,124 @@ -# DAVEH +output "ecs_service_id" { + description = "ARN that identifies the service" + value = module.ecs_service.id +} + +output "ecs_service_name" { + description = "Name of the service" + value = module.ecs_service.name +} + +output "autoscaling_policies" { + description = "Map of autoscaling policies and their attributes" + value = module.ecs_service.autoscaling_policies +} + +output "autoscaling_scheduled_actions" { + description = "Map of autoscaling scheduled actions and their attributes" + value = module.ecs_service.autoscaling_scheduled_actions +} + +output "container_definitions" { + description = "Container definitions" + value = module.ecs_service.container_definitions +} + +output "iam_role_arn" { + description = "Service IAM role ARN" + value = module.ecs_service.iam_role_arn +} + +output "iam_role_name" { + description = "Service IAM role name" + value = module.ecs_service.iam_role_name +} + +output "iam_role_unique_id" { + description = "Stable and unique string identifying the service IAM role" + value = module.ecs_service.iam_role_unique_id +} + +output "infrastructure_iam_role_arn" { + description = "Infrastructure IAM role ARN" + value = module.ecs_service.infrastructure_iam_role_arn +} + +output "infrastructure_iam_role_name" { + description = "Infrastructure IAM role name" + value = module.ecs_service.infrastructure_iam_role_name +} + +output "security_group_arn" { + description = "ARN of the security group" + value = module.ecs_service.security_group_arn +} + +output "security_group_id" { + description = "ID of the security group" + value = module.ecs_service.security_group_id +} + +output "task_definition_arn" { + description = "Full ARN of the Task Definition (including both `family` and `revision`)" + value = module.ecs_service.task_definition_arn +} + +output "task_definition_family" { + description = "The unique name of the task definition" + value = module.ecs_service.task_definition_family +} + +output "task_definition_revision" { + description = "Revision of the task in a particular family" + value = module.ecs_service.task_definition_revision +} + +output "task_exec_iam_role_arn" { + description = "Task execution IAM role ARN" + value = module.ecs_service.task_exec_iam_role_arn +} + +output "task_exec_iam_role_name" { + description = "Task execution IAM role name" + value = module.ecs_service.task_exec_iam_role_name +} + +output "task_exec_iam_role_unique_id" { + description = "Stable and unique string identifying the task execution IAM role" + value = module.ecs_service.task_exec_iam_role_unique_id +} + +output "task_set_arn" { + description = "The ARN that identifies the task set" + value = module.ecs_service.task_set_arn +} + +output "task_set_id" { + description = "The ID of the task set" + value = module.ecs_service.task_set_id +} + +output "task_set_stability_status" { + description = "The stability status. This indicates whether the task set has reached a steady state" + value = module.ecs_service.task_set_stability_status +} + +output "task_set_status" { + description = "The status of the task set" + value = module.ecs_service.task_set_status +} + +output "tasks_iam_role_arn" { + description = "Tasks IAM role ARN" + value = module.ecs_service.tasks_iam_role_arn +} + +output "tasks_iam_role_name" { + description = "Tasks IAM role name" + value = module.ecs_service.tasks_iam_role_name +} + +output "tasks_iam_role_unique_id" { + description = "Stable and unique string identifying the tasks IAM role" + value = module.ecs_service.tasks_iam_role_unique_id +} From 5d0b1349ead8b70cb92084508ba9750e0808ffbd Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:47:46 +0100 Subject: [PATCH 027/118] docs(ecs-service): remove unnecessary comment --- infrastructure/modules/ecs-service/README.md | 2 +- infrastructure/modules/ecs-service/variables.tf | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index d8895c6e..993603fe 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -43,7 +43,7 @@ No resources. | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | | [capacity\_provider\_strategy](#input\_capacity\_provider\_strategy) | Capacity provider strategies to use for the service. Can be one or more |
map(object({
base = optional(number)
capacity_provider = string
weight = optional(number)
}))
| `null` | no | | [cluster\_arn](#input\_cluster\_arn) | ARN of the ECS cluster where the resources will be provisioned | `string` | `""` | no | -| [container\_definitions](#input\_container\_definitions) | A map of valid container definitions . Please note that you should only provide values that are part of the container definition document |
map(object({
create = optional(bool, true)
operating_system_family = optional(string)
tags = optional(map(string)) # Container definition
command = optional(list(string))
cpu = optional(number)
credentialSpecs = optional(list(string))
dependsOn = optional(list(object({
condition = string
containerName = string
})))
disableNetworking = optional(bool)
dnsSearchDomains = optional(list(string))
dnsServers = optional(list(string))
dockerLabels = optional(map(string))
dockerSecurityOptions = optional(list(string))
# DAVEH: following line was comment to preceeding line
enable_execute_command = optional(bool, false) # Set in standalone variable
entrypoint = optional(list(string))
environment = optional(list(object({
name = string
value = string
})))
environmentFiles = optional(list(object({
type = string
value = string
})))
essential = optional(bool)
extraHosts = optional(list(object({
hostname = string
ipAddress = string
})))
firelensConfiguration = optional(object({
options = optional(map(string))
type = optional(string)
}))
healthCheck = optional(object({
command = optional(list(string), [])
interval = optional(number, 30)
retries = optional(number, 3)
startPeriod = optional(number)
timeout = optional(number, 5)
}))
hostname = optional(string)
image = optional(string)
interactive = optional(bool)
links = optional(list(string))
linuxParameters = optional(object({
capabilities = optional(object({
add = optional(list(string))
drop = optional(list(string))
}))
devices = optional(list(object({
containerPath = optional(string)
hostPath = optional(string)
permissions = optional(list(string))
})))
initProcessEnabled = optional(bool)
maxSwap = optional(number)
sharedMemorySize = optional(number)
swappiness = optional(number)
tmpfs = optional(list(object({
containerPath = string
mountOptions = optional(list(string))
size = number
})))
}))
logConfiguration = optional(object({
logDriver = optional(string)
options = optional(map(string))
secretOptions = optional(list(object({
name = string
valueFrom = string
})))
}))
memory = optional(number)
memoryReservation = optional(number)
mountPoints = optional(list(object({
containerPath = optional(string)
readOnly = optional(bool)
sourceVolume = optional(string)
})))
name = optional(string)
portMappings = optional(list(object({
appProtocol = optional(string)
containerPort = optional(number)
containerPortRange = optional(string)
hostPort = optional(number)
name = optional(string)
protocol = optional(string)
})))
privileged = optional(bool)
pseudoTerminal = optional(bool)
readonlyRootFilesystem = optional(bool)
repositoryCredentials = optional(object({
credentialsParameter = optional(string)
}))
resourceRequirements = optional(list(object({
type = string
value = string
})))
restartPolicy = optional(object({
enabled = optional(bool)
ignoredExitCodes = optional(list(number))
restartAttemptPeriod = optional(number)
}))
secrets = optional(list(object({
name = string
valueFrom = string
})))
startTimeout = optional(number, 30)
stopTimeout = optional(number, 120)
systemControls = optional(list(object({
namespace = optional(string)
value = optional(string)
})))
ulimits = optional(list(object({
hardLimit = number
name = string
softLimit = number
})))
user = optional(string)
versionConsistency = optional(string)
volumesFrom = optional(list(object({
readOnly = optional(bool)
sourceContainer = optional(string)
})))
workingDirectory = optional(string)
# Cloudwatch Log Group
service = optional(string)
enable_cloudwatch_logging = optional(bool)
create_cloudwatch_log_group = optional(bool)
cloudwatch_log_group_name = optional(string)
cloudwatch_log_group_use_name_prefix = optional(bool)
cloudwatch_log_group_class = optional(string)
cloudwatch_log_group_retention_in_days = optional(number)
cloudwatch_log_group_kms_key_id = optional(string)
}))
| `{}` | no | +| [container\_definitions](#input\_container\_definitions) | A map of valid container definitions . Please note that you should only provide values that are part of the container definition document |
map(object({
create = optional(bool, true)
operating_system_family = optional(string)
tags = optional(map(string)) # Container definition
command = optional(list(string))
cpu = optional(number)
credentialSpecs = optional(list(string))
dependsOn = optional(list(object({
condition = string
containerName = string
})))
disableNetworking = optional(bool)
dnsSearchDomains = optional(list(string))
dnsServers = optional(list(string))
dockerLabels = optional(map(string))
dockerSecurityOptions = optional(list(string))
enable_execute_command = optional(bool, false) # Set in standalone variable
entrypoint = optional(list(string))
environment = optional(list(object({
name = string
value = string
})))
environmentFiles = optional(list(object({
type = string
value = string
})))
essential = optional(bool)
extraHosts = optional(list(object({
hostname = string
ipAddress = string
})))
firelensConfiguration = optional(object({
options = optional(map(string))
type = optional(string)
}))
healthCheck = optional(object({
command = optional(list(string), [])
interval = optional(number, 30)
retries = optional(number, 3)
startPeriod = optional(number)
timeout = optional(number, 5)
}))
hostname = optional(string)
image = optional(string)
interactive = optional(bool)
links = optional(list(string))
linuxParameters = optional(object({
capabilities = optional(object({
add = optional(list(string))
drop = optional(list(string))
}))
devices = optional(list(object({
containerPath = optional(string)
hostPath = optional(string)
permissions = optional(list(string))
})))
initProcessEnabled = optional(bool)
maxSwap = optional(number)
sharedMemorySize = optional(number)
swappiness = optional(number)
tmpfs = optional(list(object({
containerPath = string
mountOptions = optional(list(string))
size = number
})))
}))
logConfiguration = optional(object({
logDriver = optional(string)
options = optional(map(string))
secretOptions = optional(list(object({
name = string
valueFrom = string
})))
}))
memory = optional(number)
memoryReservation = optional(number)
mountPoints = optional(list(object({
containerPath = optional(string)
readOnly = optional(bool)
sourceVolume = optional(string)
})))
name = optional(string)
portMappings = optional(list(object({
appProtocol = optional(string)
containerPort = optional(number)
containerPortRange = optional(string)
hostPort = optional(number)
name = optional(string)
protocol = optional(string)
})))
privileged = optional(bool)
pseudoTerminal = optional(bool)
readonlyRootFilesystem = optional(bool)
repositoryCredentials = optional(object({
credentialsParameter = optional(string)
}))
resourceRequirements = optional(list(object({
type = string
value = string
})))
restartPolicy = optional(object({
enabled = optional(bool)
ignoredExitCodes = optional(list(number))
restartAttemptPeriod = optional(number)
}))
secrets = optional(list(object({
name = string
valueFrom = string
})))
startTimeout = optional(number, 30)
stopTimeout = optional(number, 120)
systemControls = optional(list(object({
namespace = optional(string)
value = optional(string)
})))
ulimits = optional(list(object({
hardLimit = number
name = string
softLimit = number
})))
user = optional(string)
versionConsistency = optional(string)
volumesFrom = optional(list(object({
readOnly = optional(bool)
sourceContainer = optional(string)
})))
workingDirectory = optional(string)
# Cloudwatch Log Group
service = optional(string)
enable_cloudwatch_logging = optional(bool)
create_cloudwatch_log_group = optional(bool)
cloudwatch_log_group_name = optional(string)
cloudwatch_log_group_use_name_prefix = optional(bool)
cloudwatch_log_group_class = optional(string)
cloudwatch_log_group_retention_in_days = optional(number)
cloudwatch_log_group_kms_key_id = optional(string)
}))
| `{}` | 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 | | [cpu](#input\_cpu) | Number of cpu units used by the task. If the `requires_compatibilities` is `FARGATE` this field is required | `number` | `1024` | no | | [create\_iam\_role](#input\_create\_iam\_role) | Determines whether the ECS service IAM role should be created | `bool` | `true` | no | diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 78fc6bbc..bb6f8fa8 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -241,12 +241,11 @@ variable "container_definitions" { condition = string containerName = string }))) - disableNetworking = optional(bool) - dnsSearchDomains = optional(list(string)) - dnsServers = optional(list(string)) - dockerLabels = optional(map(string)) - dockerSecurityOptions = optional(list(string)) - # DAVEH: following line was comment to preceeding line + disableNetworking = optional(bool) + dnsSearchDomains = optional(list(string)) + dnsServers = optional(list(string)) + dockerLabels = optional(map(string)) + dockerSecurityOptions = optional(list(string)) enable_execute_command = optional(bool, false) # Set in standalone variable entrypoint = optional(list(string)) environment = optional(list(object({ From 1f4f5329c65d905f9fcc3b604e94a73cb45f7439 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 13:41:41 +0100 Subject: [PATCH 028/118] docs(ecs-service): write intro --- infrastructure/modules/ecs-service/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 993603fe..3e0e38cd 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -1,4 +1,8 @@ -# DAVEH +# ECS-Service + +NHS Screening wrapper around the community +[`terraform-aws-modules/ecs/aws//modules/service`](https://registry.terraform.io/modules/terraform-aws-modules/ecs/aws/latest/submodules/service) +submodule that consumes the shared `context.tf` for naming and tagging. From 509cf51755e6567b15aca8511078885e98890930 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 13:45:08 +0100 Subject: [PATCH 029/118] docs(ecs-service): write examples --- infrastructure/modules/ecs-service/README.md | 106 +++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 3e0e38cd..b24505c1 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -4,6 +4,112 @@ NHS Screening wrapper around the community [`terraform-aws-modules/ecs/aws//modules/service`](https://registry.terraform.io/modules/terraform-aws-modules/ecs/aws/latest/submodules/service) submodule that consumes the shared `context.tf` for naming and tagging. +## Usage + +### Minimal Fargate service + +```hcl +module "api_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + + service = "bcss" + project = "api" + environment = "development" + name = "web-api" + cluster_arn = module.ecs_cluster.arn + subnet_ids = module.vpc.private_subnets + + container_definitions = { + app = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/my-app:latest" + cpu = 512 + memory = 1024 + essential = true + } + } +} +``` + +### Fargate service with load balancer + +```hcl +module "web_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + + service = "bcss" + project = "web" + environment = "prod" + name = "frontend" + cluster_arn = module.ecs_cluster.arn + subnet_ids = module.vpc.private_subnets + assign_public_ip = false + + container_definitions = { + web = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/web-app:v1.2.3" + cpu = 512 + memory = 1024 + essential = true + port_mappings = [{ + container_port = 8080 + host_port = 8080 + }] + } + } + + load_balancer = { + web = { + container_name = "web" + container_port = 8080 + target_group_arn = module.alb.target_group_arn + } + } + + health_check_grace_period_seconds = 60 +} +``` + +### Fargate service with autoscaling + +```hcl +module "worker_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + + service = "bcss" + project = "jobs" + environment = "prod" + name = "background-worker" + cluster_arn = module.ecs_cluster.arn + subnet_ids = module.vpc.private_subnets + + container_definitions = { + worker = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/worker:latest" + cpu = 256 + memory = 512 + essential = true + } + } + + desired_count = 2 + enable_autoscaling = true + autoscaling_min_capacity = 2 + autoscaling_max_capacity = 10 + + autoscaling_policies = { + cpu = { + policy_type = "TargetTrackingScaling" + target_tracking_scaling_policy_configuration = { + target_value = 70.0 + predefined_metric_specification = { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + } + } + } +} +``` + From 0d4df63fef7f11a3d7adf48534a7191bd15b1a86 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 22 Jun 2026 16:48:08 +0100 Subject: [PATCH 030/118] feat(ecs-service): enhance README and add locals for service name management --- infrastructure/modules/ecs-service/README.md | 196 ++++++++++++++++-- infrastructure/modules/ecs-service/locals.tf | 6 + infrastructure/modules/ecs-service/main.tf | 20 +- .../modules/ecs-service/variables.tf | 13 ++ 4 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 infrastructure/modules/ecs-service/locals.tf diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index b24505c1..98a686e5 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -1,8 +1,20 @@ -# ECS-Service +# ECS Service NHS Screening wrapper around the community [`terraform-aws-modules/ecs/aws//modules/service`](https://registry.terraform.io/modules/terraform-aws-modules/ecs/aws/latest/submodules/service) -submodule that consumes the shared `context.tf` for naming and tagging. +submodule that enforces the platform's baseline controls and consumes +the shared `context.tf` for naming and tagging. + +## What this module enforces + +|Control|How it is enforced| +|---|---| +|No public IP|`assign_public_ip` defaults to `false`; tasks run on private subnets| +|ECS-managed tags|`enable_ecs_managed_tags` defaults to `true`; AWS propagates resource tags to tasks| +|Tag propagation|`propagate_tags` defaults to `TASK_DEFINITION` so tasks inherit service tags| +|Creation gate|`create = module.this.enabled`; no resources are created when the module is disabled| +|Consistent naming|Service name is sourced from `local.service_name` (context-derived or `var.service_name` override)| +|Consistent tagging|All resources tagged via `module.this.tags`| ## Usage @@ -10,7 +22,7 @@ submodule that consumes the shared `context.tf` for naming and tagging. ```hcl module "api_service" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" service = "bcss" project = "api" @@ -34,15 +46,16 @@ module "api_service" { ```hcl module "web_service" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" - service = "bcss" - project = "web" - environment = "prod" - name = "frontend" - cluster_arn = module.ecs_cluster.arn - subnet_ids = module.vpc.private_subnets - assign_public_ip = false + service = "bcss" + project = "web" + environment = "prod" + name = "frontend" + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets container_definitions = { web = { @@ -73,14 +86,16 @@ module "web_service" { ```hcl module "worker_service" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" - service = "bcss" - project = "jobs" - environment = "prod" - name = "background-worker" - cluster_arn = module.ecs_cluster.arn - subnet_ids = module.vpc.private_subnets + service = "bcss" + project = "jobs" + environment = "prod" + name = "background-worker" + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets container_definitions = { worker = { @@ -110,6 +125,148 @@ module "worker_service" { } ``` +### Service reading secrets from Secrets Manager + +```hcl +module "api_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" + + service = "bcss" + project = "api" + environment = "prod" + name = "web-api" + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets + + # Grant the task execution role permission to read these secrets. + # The values are injected into the container at runtime via the + # secrets block in the container definition below. + task_exec_secret_arns = [ + module.db_credentials.secret_arn, + module.api_key.secret_arn, + ] + + container_definitions = { + app = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/my-app:latest" + cpu = 512 + memory = 1024 + essential = true + + secrets = [ + { + name = "DB_PASSWORD" + valueFrom = module.db_credentials.secret_arn + }, + { + name = "API_KEY" + valueFrom = module.api_key.secret_arn + }, + ] + } + } +} +``` + +### Blue/green deployment + +```hcl +module "api_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" + + service = "bcss" + project = "api" + environment = "prod" + name = "web-api" + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets + + deployment_configuration = { + strategy = "BLUE_GREEN" + bake_time_in_minutes = 5 + } + + deployment_circuit_breaker = { + enable = true + rollback = true + } + + container_definitions = { + app = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/my-app:latest" + cpu = 512 + memory = 1024 + essential = true + } + } +} +``` + +### Preserving an existing service name + +Use `service_name` when you wish to explicitly define a service name i.e. when the ECS service was previously created outside Terraform and the name must be kept stable: + +```hcl +module "legacy_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" + + service = "bcss" + project = "api" + environment = "prod" + name = "web-api" + + service_name = "bcss-legacy-worker" # overrides the context-derived name + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets + + container_definitions = { + app = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/my-app:latest" + cpu = 256 + memory = 512 + essential = true + } + } +} +``` + +## Conventions + +- Service name defaults to `module.this.name` (derived from `context.tf`). Supply + `service`, `project`, `environment`, and `name` inputs to control the generated + name. Set `service_name` to override the context-derived name with an explicit + value — useful when an existing ECS service name must be preserved. +- `assign_public_ip` defaults to `false`. Only set it to `true` for public-facing + Fargate services that intentionally require direct internet access; this is rare + in the NHS Screening platform. +- `enable_autoscaling` defaults to `true`. Set it to `false` for batch or + short-lived services that do not require auto-scaling. +- `desired_count` defaults to `1`. Adjust to match your workload requirements. +- The caller must supply the ECS cluster ARN (`cluster_arn`) and the VPC subnet + IDs (`subnet_ids`). These are not managed by this module. + +## What this module does NOT do + +- Create an ECS cluster. Use the `ecs-cluster` module and pass the ARN via + `cluster_arn`. +- Create VPCs, subnets, or security groups. The caller is responsible for + networking; pass existing security group IDs via `security_group_ids` or let + the module create one via `create_security_group = true`. +- Create load balancers or target groups. Configure these separately and pass + the target group ARN(s) via `load_balancer`. +- Build or push container images. Use the `ecr` module for the registry. +- Manage KMS encryption at the ECS service level. Task-level secrets encryption + is handled via the task execution IAM role and the `secrets-manager` or + `parameter_store` modules. +- Create CloudWatch log groups. Define these in your container definitions or + provision them separately. + @@ -128,7 +285,7 @@ No providers. | Name | Source | Version | | ---- | ------ | ------- | -| [ecs\_service](#module\_ecs\_service) | terraform-aws-modules/ecs/aws//modules/service | ~> 7.5.0 | +| [ecs\_service](#module\_ecs\_service) | terraform-aws-modules/ecs/aws//modules/service | 7.5.0 | | [this](#module\_this) | ../tags | n/a | ## Resources @@ -238,6 +395,7 @@ No resources. | [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 | | [service\_connect\_configuration](#input\_service\_connect\_configuration) | The ECS Service Connect configuration for this service to discover and connect to services, and be discovered by, and connected from, other services within a namespace |
object({
enabled = optional(bool, true)
access_log_configuration = optional(object({
format = string
include_query_parameters = optional(string)
}))
log_configuration = optional(object({
log_driver = string
options = optional(map(string))
secret_option = optional(list(object({
name = string
value_from = string
})))
}))
namespace = optional(string)
service = optional(list(object({
client_alias = optional(object({
dns_name = optional(string)
port = number
test_traffic_rules = optional(list(object({
header = optional(object({
name = string
value = object({
exact = string
})
}))
})))
}))
discovery_name = optional(string)
ingress_port_override = optional(number)
port_name = string
timeout = optional(object({
idle_timeout_seconds = optional(number)
per_request_timeout_seconds = optional(number)
}))
tls = optional(object({
issuer_cert_authority = object({
aws_pca_authority_arn = string
})
kms_key = optional(string)
role_arn = optional(string)
}))
})))
})
| `null` | no | +| [service\_name](#input\_service\_name) | Name of the service | `string` | `null` | no | | [service\_registries](#input\_service\_registries) | Service discovery registries for the service |
object({
container_name = optional(string)
container_port = optional(number)
port = optional(number)
registry_arn = string
})
| `null` | no | | [service\_tags](#input\_service\_tags) | A map of additional tags to add to the service | `map(string)` | `{}` | no | | [sigint\_rollback](#input\_sigint\_rollback) | Whether to enable graceful termination of deployments using SIGINT signals. Only applicable when using ECS deployment controller and requires wait\_for\_steady\_state = true. Default is false | `bool` | `null` | no | diff --git a/infrastructure/modules/ecs-service/locals.tf b/infrastructure/modules/ecs-service/locals.tf new file mode 100644 index 00000000..4ff52fa3 --- /dev/null +++ b/infrastructure/modules/ecs-service/locals.tf @@ -0,0 +1,6 @@ +locals { + # Service name is derived from context. The community module receives + # module.this.name directly so that context-driven label ordering is + # preserved without an intermediate local in the common case. + service_name = var.service_name != null ? var.service_name : module.this.name +} diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 9f98e545..054ea646 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -1,9 +1,25 @@ +################################################################ +# ECS Service +# +# Thin NHS wrapper around the community ECS service submodule +# (terraform-aws-modules/ecs/aws//modules/service) that +# enforces the screening platform's baseline controls: +# +# * No public IP: assign_public_ip defaults to false +# * ECS-managed tags: enable_ecs_managed_tags defaults to true +# * Tag propagation: propagate_tags defaults to TASK_DEFINITION +# * Creation gated by module.this.enabled +# +# Naming: context.tf via module.this by default; caller may override +# with var.service_name. Tagging is always via module.this.tags. +################################################################ + module "ecs_service" { source = "terraform-aws-modules/ecs/aws//modules/service" - version = "~> 7.5.0" + version = "7.5.0" create = module.this.enabled - name = module.this.name + name = local.service_name tags = module.this.tags alarms = var.alarms diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index bb6f8fa8..bdb6ed0c 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -1,3 +1,10 @@ +################################################################ +# ECS Service-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# context.tf via `module.this`. +################################################################ + variable "alarms" { description = "Information about the CloudWatch alarms" type = object({ @@ -884,6 +891,12 @@ variable "service_connect_configuration" { default = null } +variable "service_name" { + description = "Name of the service" + type = string + default = null +} + variable "service_registries" { description = "Service discovery registries for the service" type = object({ From 990fe1ca3cd056da2c6295817f070feceeab7257 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 08:55:05 +0100 Subject: [PATCH 031/118] 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 350f90814ccda890f96398ab22282243b5323c88 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 22 Jun 2026 23:18:14 +0100 Subject: [PATCH 032/118] chore: Dependabot PRs #32, #60, #90 (#91) * Bump requests in /docs/adr/assets/ADR-003/examples/python Bumps [requests](https://github.com/psf/requests) from 2.32.0 to 2.33.0. - [Release notes](https://github.com/psf/requests/releases) - [Changelog](https://github.com/psf/requests/blob/main/HISTORY.md) - [Commits](https://github.com/psf/requests/compare/v2.32.0...v2.33.0) --- updated-dependencies: - dependency-name: requests dependency-version: 2.33.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * build(deps): bump pyjwt in /docs/adr/assets/ADR-003/examples/python Bumps [pyjwt](https://github.com/jpadilla/pyjwt) from 2.8.0 to 2.13.0. - [Release notes](https://github.com/jpadilla/pyjwt/releases) - [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst) - [Commits](https://github.com/jpadilla/pyjwt/compare/2.8.0...2.13.0) --- updated-dependencies: - dependency-name: pyjwt dependency-version: 2.13.0 dependency-type: direct:production ... Signed-off-by: dependabot[bot] * build(deps): bump golang.org/x/net Bumps [golang.org/x/net](https://github.com/golang/net) from 0.23.0 to 0.38.0. - [Commits](https://github.com/golang/net/compare/v0.23.0...v0.38.0) --- updated-dependencies: - dependency-name: golang.org/x/net dependency-version: 0.38.0 dependency-type: indirect ... Signed-off-by: dependabot[bot] --------- Signed-off-by: dependabot[bot] Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com> --- docs/adr/assets/ADR-003/examples/golang/go.mod | 4 ++-- docs/adr/assets/ADR-003/examples/golang/go.sum | 4 ++-- docs/adr/assets/ADR-003/examples/python/requirements.txt | 4 ++-- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/adr/assets/ADR-003/examples/golang/go.mod b/docs/adr/assets/ADR-003/examples/golang/go.mod index 0cdf5e68..5e66241a 100644 --- a/docs/adr/assets/ADR-003/examples/golang/go.mod +++ b/docs/adr/assets/ADR-003/examples/golang/go.mod @@ -1,10 +1,10 @@ module github-app-get-tokent -go 1.21.0 +go 1.23.0 require ( github.com/go-resty/resty/v2 v2.7.0 github.com/golang-jwt/jwt v3.2.2+incompatible ) -require golang.org/x/net v0.23.0 // indirect +require golang.org/x/net v0.38.0 // indirect diff --git a/docs/adr/assets/ADR-003/examples/golang/go.sum b/docs/adr/assets/ADR-003/examples/golang/go.sum index 646b27e4..cd849816 100644 --- a/docs/adr/assets/ADR-003/examples/golang/go.sum +++ b/docs/adr/assets/ADR-003/examples/golang/go.sum @@ -3,8 +3,8 @@ github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSM github.com/golang-jwt/jwt v3.2.2+incompatible h1:IfV12K8xAKAnZqdXVzCZ+TOjboZ2keLg81eXfW3O+oY= github.com/golang-jwt/jwt v3.2.2+incompatible/go.mod h1:8pz2t5EyA70fFQQSrl6XZXzqecmYZeUEB8OUGHkxJ+I= golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= -golang.org/x/net v0.23.0 h1:7EYJ93RZ9vYSZAIb2x3lnuvqO5zneoD6IvWjuhfxjTs= -golang.org/x/net v0.23.0/go.mod h1:JKghWKKOSdJwpW2GEx0Ja7fmaKnMsbu+MWVZTokSYmg= +golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= +golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sys v0.0.0-20201119102817-f84b799fce68/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20210423082822-04245dca01da/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo= diff --git a/docs/adr/assets/ADR-003/examples/python/requirements.txt b/docs/adr/assets/ADR-003/examples/python/requirements.txt index 22784e07..0081b1d6 100644 --- a/docs/adr/assets/ADR-003/examples/python/requirements.txt +++ b/docs/adr/assets/ADR-003/examples/python/requirements.txt @@ -1,2 +1,2 @@ -PyJWT==2.8.0 -requests==2.32.0 +PyJWT==2.13.0 +requests==2.33.0 From 125bb144db86ec7ddc50f3a5be0307cc0a348d97 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 00:35:01 +0100 Subject: [PATCH 033/118] ci: enhance secret scanning with new hooks for both staged changes and complete history (#92) --- .../pre-commit-hooks.instructions.md | 3 +- .github/skills/pre-commit-hooks.skill.md | 76 +++++++++++++++---- .github/workflows/stage-1-pre-commit.yml | 2 +- .pre-commit-config.yaml | 10 ++- .../user-guides/Pre_commit_hooks_reference.md | 71 ++++++++++++++--- docs/user-guides/Scan_secrets.md | 60 +++++++++++++-- 6 files changed, 188 insertions(+), 34 deletions(-) diff --git a/.github/instructions/pre-commit-hooks.instructions.md b/.github/instructions/pre-commit-hooks.instructions.md index db5d4b5d..bf207fc3 100644 --- a/.github/instructions/pre-commit-hooks.instructions.md +++ b/.github/instructions/pre-commit-hooks.instructions.md @@ -42,7 +42,8 @@ git commit -m "type(scope): description" - `detect-aws-credentials` — detects embedded secrets - `detect-private-key` — detects leaked private keys -- `scan-secrets` — scans git history for secrets +- `scan-secrets-staged-changes` — scans staged changes for secrets (runs on `git commit`) +- `scan-secrets-whole-history` — scans entire git history for secrets (runs on `pre-commit run --all-files`) - `terraform_validate` — ensures modules are syntactically valid - `regenerate-dependabot-config` — ensures Dependabot watches all modules - `no-commit-to-branch` — enforces PR workflow diff --git a/.github/skills/pre-commit-hooks.skill.md b/.github/skills/pre-commit-hooks.skill.md index 5d512f5e..e6ac26d8 100644 --- a/.github/skills/pre-commit-hooks.skill.md +++ b/.github/skills/pre-commit-hooks.skill.md @@ -509,51 +509,94 @@ vale README.md infrastructure/AGENTS.md ### Category 5: Security & Secrets -#### 5.1 `scan-secrets` — Secret Scanning via Gitleaks +#### 5.1 `scan-secrets-staged-changes` — Secret Scanning (Staged Files) -**What it does:** Scans entire git history for embedded secrets (API keys, credentials, etc.) using Gitleaks. +**What it does:** Scans staged changes for embedded secrets (API keys, credentials, etc.) using Gitleaks. Runs automatically on `git commit`. **When it fails:** ```text Leaks found: 1 -File: .env.example +File: .env Secret: aws_secret_access_key = "AKIA2EXAMPLE..." ``` **Common false positives:** +- Example/placeholder credentials in `.env.example` +- Test data that looks like credentials + +**Fix:** + +##### Staged changes: Real secret detected + +```bash +# Unstage the file immediately +git reset .env + +# Remove or edit to remove the secret +rm .env # or edit to remove secrets + +# Stage clean version and commit +git add .env +git commit -m "fix: remove secret from env" +``` + +##### Staged changes: False positive detected + +```bash +# Add to .gitleaksignore +echo "commit-sha:path/to/file:rule-type:line-number" >> .gitleaksignore + +# Re-stage and commit +git add .gitleaksignore +git commit -m "chore: ignore false positive" +``` + +--- + +#### 5.2 `scan-secrets-whole-history` — Secret Scanning (Complete History) + +**What it does:** Scans entire git history for embedded secrets using Gitleaks. Runs on `pre-commit run --all-files` or in CI/CD. + +**When it fails:** + +```text +Leaks found: 1 +File: config/old-backup.tf (in commit abc1234) +Secret: aws_access_key_id = "AKIAIOSFODNN7EXAMPLE" +``` + +**Common false positives:** + - Example/placeholder credentials in `.env.example` - Test data that looks like credentials - Version strings mistaken for IPv4 addresses **Fix:** -#### Option 1: Real secret (CRITICAL) +##### History scan: Real secret in git history (CRITICAL) ```bash -# Remove the secret immediately +# Remove from history (destructive operation) git filter-branch --force --index-filter \ - 'git rm --cached --ignore-unmatch PATH_TO_FILE' \ + 'git rm --cached --ignore-unmatch config/old-backup.tf' \ --prune-empty --tag-name-filter cat -- --all -# Force push (warning: destructive) +# Force push to remove from remote git push origin +main + +# IMPORTANT: Regenerate/rotate any exposed credentials ``` -#### Option 2: False positive (Add to ignore list) +##### History scan: False positive in git history ```bash -# Get the fingerprint from the error # Add to .gitleaksignore echo "commit-sha:path/to/file:rule-type:line-number" >> .gitleaksignore -``` - -**Manual run:** -```bash -gitleaks detect --verbose -gitleaks detect -i .gitleaksignore # With ignores +# Re-run to verify +pre-commit run scan-secrets-whole-history --all-files ``` --- @@ -764,7 +807,8 @@ fi | `check-english-usage` | `.md` | ❌ No | Low | Format | | `detect-aws-credentials` | All | ❌ No | **CRITICAL** | Security | | `detect-private-key` | All | ❌ No | **CRITICAL** | Security | -| `scan-secrets` | All | ❌ No | **CRITICAL** | Security | +| `scan-secrets-staged-changes` | All | ❌ No | **CRITICAL** | Security | +| `scan-secrets-whole-history` | All | ❌ No | **CRITICAL** | Security | | `conventional-commit` | Commit msg | ❌ No | Medium | Commit | | `no-commit-to-branch` | N/A | ❌ No | High | Git | diff --git a/.github/workflows/stage-1-pre-commit.yml b/.github/workflows/stage-1-pre-commit.yml index e0fd958d..668d4918 100644 --- a/.github/workflows/stage-1-pre-commit.yml +++ b/.github/workflows/stage-1-pre-commit.yml @@ -33,7 +33,7 @@ jobs: - name: "shell-and-content" hooks: >- shellcheck check-file-format check-markdown-format - check-english-usage scan-secrets + check-english-usage scan-secrets-whole-history - name: "terraform-format-lint" hooks: >- generate-terraform-providers terraform_fmt terraform_tflint diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index d5cc4fa4..ad7b29be 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -180,8 +180,14 @@ repos: files: \.tf(vars)?$ pass_filenames: false - - id: scan-secrets - name: scan-secrets + - id: scan-secrets-staged-changes + name: scan-secrets-staged-changes + entry: bash -c 'check=staged-changes ./scripts/githooks/scan-secrets.sh' + language: system + pass_filenames: false + + - id: scan-secrets-whole-history + name: scan-secrets-whole-history entry: bash -c 'check=whole-history ./scripts/githooks/scan-secrets.sh' language: system pass_filenames: false diff --git a/docs/user-guides/Pre_commit_hooks_reference.md b/docs/user-guides/Pre_commit_hooks_reference.md index 54d48c00..55bf15ca 100644 --- a/docs/user-guides/Pre_commit_hooks_reference.md +++ b/docs/user-guides/Pre_commit_hooks_reference.md @@ -49,7 +49,7 @@ Pre-commit hooks are **automated quality checks** that run before every commit. - Prevent secrets from being committed - Save CI/CD time by fixing issues early -**This repository has 26 hooks** covering Terraform, shell scripts, file formatting, security scanning, and commit message validation. +**This repository has 27 hooks** covering Terraform, shell scripts, file formatting, security scanning, and commit message validation. --- @@ -535,15 +535,17 @@ AWS credentials detected --- -#### `scan-secrets` — Gitleaks +#### `scan-secrets-staged-changes` — Gitleaks (staged files only) -**What it does:** Scans entire git history for embedded secrets (API keys, credentials, etc.). +**What it does:** Scans only the staged changes for embedded secrets (API keys, credentials, etc.). + +**When to use:** During pre-commit (automatically runs on `git commit`). Catches secrets before they're committed. **When it fails:** ```text Leaks found: 1 -File: .env.example +File: .env Secret: aws_secret_access_key = "AKIA..." ``` @@ -551,14 +553,60 @@ Secret: aws_secret_access_key = "AKIA..." If it's a **real secret** (CRITICAL): +```bash +# Unstage the file +git reset .env + +# Remove from working directory (or edit to remove the secret) +rm .env # or edit to remove secrets + +# Stage and commit the corrected version +git add .env +git commit -m "fix: remove secrets" +``` + +If it's a **false positive** (e.g., example credentials): + +```bash +# Add to .gitleaksignore +echo "commit-sha:path/to/file:rule-type:line-number" >> .gitleaksignore + +# Re-stage and commit +git add .gitleaksignore +git commit -m "chore: ignore false positive secret scan" +``` + +--- + +#### `scan-secrets-whole-history` — Gitleaks (complete history) + +**What it does:** Scans entire git history for embedded secrets (API keys, credentials, etc.). Runs on `pre-commit run --all-files` or in CI/CD. + +**When to use:** Full repository scans (CI/CD, local validation, before pushing to remote). + +**When it fails:** + +```text +Leaks found: 1 +File: config/old-backup.tf +Secret: aws_access_key_id = "AKIAIOSFODNN7EXAMPLE" +Commit: abc1234 +``` + +**Fix:** + +If it's a **real secret** (CRITICAL — secret is in history): + ```bash # Use git filter-branch to remove from history git filter-branch --force --index-filter \ - 'git rm --cached --ignore-unmatch PATH_TO_FILE' \ + 'git rm --cached --ignore-unmatch config/old-backup.tf' \ --prune-empty --tag-name-filter cat -- --all # Force push to remove from remote git push origin +main + +# Regenerate any AWS/API credentials that were exposed ``` If it's a **false positive** (e.g., example credentials): @@ -568,13 +616,17 @@ If it's a **false positive** (e.g., example credentials): echo "commit-sha:path/to/file:rule-type:line-number" >> .gitleaksignore # Re-run to verify -pre-commit run scan-secrets --all-files +pre-commit run scan-secrets-whole-history --all-files ``` -**Manual run:** +**Manual runs:** ```bash -gitleaks detect --verbose +# Scan staged changes only +check=staged-changes ./scripts/githooks/scan-secrets.sh + +# Scan entire history +check=whole-history ./scripts/githooks/scan-secrets.sh ``` --- @@ -768,7 +820,8 @@ git commit --no-verify -m "..." - `detect-aws-credentials` — detects leaked credentials - `detect-private-key` — detects leaked private keys -- `scan-secrets` — scans git history for secrets +- `scan-secrets-staged-changes` — scans staged changes for secrets +- `scan-secrets-whole-history` — scans entire git history for secrets If you use `--no-verify`, report the issue immediately. diff --git a/docs/user-guides/Scan_secrets.md b/docs/user-guides/Scan_secrets.md index 1e3e1e10..b8252dec 100644 --- a/docs/user-guides/Scan_secrets.md +++ b/docs/user-guides/Scan_secrets.md @@ -2,6 +2,7 @@ - [Guide: Scan secrets](#guide-scan-secrets) - [Overview](#overview) + - [Two-tier scanning: Staged changes + Complete history](#two-tier-scanning-staged-changes--complete-history) - [Key files](#key-files) - [Configuration checklist](#configuration-checklist) - [Testing](#testing) @@ -13,13 +14,54 @@ Scanning a repository for hard-coded secrets is a crucial security practice. "Ha [Gitleaks](https://github.com/gitleaks/gitleaks) is a powerful open-source tool designed to identify hard-coded secrets and other sensitive information in Git repositories. It works by scanning the commit history and the working directory for sensitive data that should not be there. +## Two-tier scanning: Staged changes + Complete history + +This repository uses two complementary secret scanning hooks to provide defense in depth: + +### `scan-secrets-staged-changes` + +- **When:** Runs automatically on `git commit` before the commit is created +- **Scope:** Scans only the files you're about to commit (staged changes) +- **Purpose:** Fast feedback loop — catches secrets before they enter the repository +- **Time:** ~1 second (very fast) + +**Fix immediately if it triggers:** + +```bash +# Unstage the file +git reset .env + +# Remove or edit to remove the secret +# Then stage and commit the corrected version +git add .env +git commit -m "fix: remove secret" +``` + +### `scan-secrets-whole-history` + +- **When:** Runs on `pre-commit run --all-files` or in CI/CD pipelines +- **Scope:** Scans entire git history (all commits) +- **Purpose:** Comprehensive audit — catches secrets that may have been committed before this hook existed +- **Time:** ~10-30 seconds (slower, but thorough) + +**Run manually:** + +```bash +# Full history scan (use before pushing to remote) +check=whole-history ./scripts/githooks/scan-secrets.sh + +# Or via pre-commit +pre-commit run scan-secrets-whole-history --all-files +``` + +**If it fails:** Secret is already in history; see [Removing sensitive data](#removing-sensitive-data) below for remediation. + ## Key files -- [`scan-secrets.sh`](../../scripts/githooks/scan-secrets.sh): A shell script that scans the codebase for hard-coded secrets +- [`scan-secrets.sh`](../../scripts/githooks/scan-secrets.sh): A shell script that scans the codebase for hard-coded secrets (supports `check=staged-changes` and `check=whole-history` modes) - [`gitleaks.toml`](../../scripts/config/gitleaks.toml): A configuration file for the secret scanner - [`.gitleaksignore`](../../.gitleaksignore): A list of fingerprints to ignore by the secret scanner - [`scan-secrets/action.yaml`](../../.github/actions/scan-secrets/action.yaml): GitHub action to run the scripts as part of the CI/CD pipeline -- [`pre-commit.yaml`](../../scripts/config/pre-commit.yaml): Run the secret scanner as a pre-commit git hook ## Configuration checklist @@ -30,10 +72,18 @@ Scanning a repository for hard-coded secrets is a crucial security practice. "Ha ## Testing -You can execute and test the secret scanning across all commits locally on a developer's workstation using the following command +You can execute and test the secret scanning locally on a developer's workstation: + +**Staged changes only (fast):** + +```bash +check=staged-changes ./scripts/githooks/scan-secrets.sh +``` + +**Entire history (comprehensive):** -```shell -ALL_FILES=true ./scripts/githooks/scan-secrets.sh +```bash +check=whole-history ./scripts/githooks/scan-secrets.sh ``` ## Removing sensitive data From c5b67851bcc90edd676b940f430df0e2dc959f1e Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Tue, 23 Jun 2026 08:25:28 +0100 Subject: [PATCH 034/118] fix: align Terraform module documentation and structure (#88) * fix(dependabot.yaml): align Terraform module paths in dependabot configuration * docs(context.tf): align context.tf files across all new modules * build: update module file structure guidelines and conditional requirements * style: standardize IAM capitalization across documentation and prompts * style(acm/main.tf): correct wording in comments to clarify module controls * docs(guardduty/README.md): enhance module documentation with detailed usage examples and conventions * refactor(iam/locals.tf): move locals configuration for IAM path and role policies to locals.tf * docs(iam/README.md): add enforcement details for IAM module controls and usage * docs(inspector/README.md): enhance module documentation with detailed usage examples and conventions * docs(kms/README.md): enhance documentation with detailed usage examples and conventions for KMS module * docs(README.md): ensure usage blocks use reference in examples * docs(lambda/README.md): enhance documentation with detailed usage examples and conventions for Lambda module * docs(license-manager/README.md): enhance documentation with detailed usage examples, conventions, and additional configuration options for License Manager module * refactor(license-manager/locals.tf): move locals configuration for IAM path and role policies to locals.tf * docs(license-manager/README.md): update module source references to use placeholder * docs(secrets-manager/README.md): update module source references to use full GitHub URL and enhance documentation with module limitations and conventions * docs(s3-bucket/README.md): update module source references to use placeholder * docs(security-hub/README.md): add conventions section detailing module defaults and configurations * docs(README.md): ensure usage blocks use reference in examples * docs(sns/README.md): enhance usage examples and conventions for SNS module * style: standardize IAM capitalization across documentation and prompts * docs(security-hub/README.md): update conventions section for clarity and formatting * docs(README.md): update module descriptions for clarity and consistency --- .github/agents/terraform-modules.agent.md | 7 +- .github/dependabot.yaml | 1 + .../terraform-modules.instructions.md | 27 +++-- .../prompts/new-terraform-module.prompt.md | 9 +- .github/skills/pre-commit-hooks.skill.md | 2 +- .../terraform-module-maintenance.skill.md | 9 +- .../skills/terraform-module-patterns.skill.md | 10 +- AGENTS.md | 4 +- README.md | 53 +++++---- .../user-guides/Pre_commit_hooks_reference.md | 2 +- infrastructure/modules/acm/README.md | 10 +- infrastructure/modules/acm/main.tf | 2 +- infrastructure/modules/guardduty/README.md | 107 ++++++++++++++++- infrastructure/modules/guardduty/context.tf | 2 +- infrastructure/modules/iam/README.md | 26 ++-- infrastructure/modules/iam/context.tf | 2 +- infrastructure/modules/iam/locals.tf | 17 +++ infrastructure/modules/iam/main.tf | 20 ---- infrastructure/modules/inspector/README.md | 111 +++++++++++++++++- infrastructure/modules/inspector/context.tf | 2 +- infrastructure/modules/kms/README.md | 110 ++++++++++++++--- infrastructure/modules/kms/context.tf | 2 +- infrastructure/modules/lambda/README.md | 111 +++++++++++++++++- infrastructure/modules/lambda/context.tf | 2 +- .../modules/license-manager/README.md | 96 ++++++++++++++- .../modules/license-manager/context.tf | 2 +- .../modules/license-manager/locals.tf | 6 + .../modules/license-manager/main.tf | 7 -- infrastructure/modules/s3-bucket/README.md | 8 +- infrastructure/modules/s3-bucket/context.tf | 2 +- .../modules/secrets-manager/README.md | 27 ++++- .../modules/secrets-manager/context.tf | 2 +- infrastructure/modules/security-hub/README.md | 21 +++- .../modules/security-hub/context.tf | 2 +- infrastructure/modules/sns/README.md | 91 +++++++++++++- infrastructure/modules/sns/context.tf | 2 +- infrastructure/modules/tags/README.md | 2 +- .../config/vocabularies/words/accept.txt | 11 +- 38 files changed, 788 insertions(+), 139 deletions(-) create mode 100644 infrastructure/modules/iam/locals.tf create mode 100644 infrastructure/modules/license-manager/locals.tf diff --git a/.github/agents/terraform-modules.agent.md b/.github/agents/terraform-modules.agent.md index 764e5343..c0e8ba3b 100644 --- a/.github/agents/terraform-modules.agent.md +++ b/.github/agents/terraform-modules.agent.md @@ -30,7 +30,7 @@ source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git All modules follow the **wrapper module pattern**: 1. Wrap a community module (e.g., `terraform-aws-modules/*`) or native resources. -2. Enforce NHS security baseline (encryption, TLS, no public access, least-privilege iam). +2. Enforce NHS security baseline (encryption, TLS, no public access, least-privilege IAM). 3. Derive naming from `module.this.id` and tagging from `module.this.tags`. 4. Gate creation via `module.this.enabled`. 5. Pin upstream versions explicitly. @@ -58,7 +58,7 @@ Every module must enforce: | Encryption at rest | KMS or service-managed; no unencrypted storage | | Encryption in transit | TLS required where applicable | | No public access | Blocked by default at all available toggles | -| iam least-privilege | No `*` actions in policies | +| IAM least-privilege | No `*` actions in policies | | Logging | Enabled where the service supports it | | Tagging | All resources via `module.this.tags` | @@ -74,7 +74,7 @@ Every module must enforce: 8. Update README when changing module interfaces. 9. Use British English in comments and documentation. 10. Never hard-code secrets, account IDs, or ARNs. -11. Never use `*` in iam policy actions. +11. Never use `*` in IAM policy actions. 12. Never edit `context.tf` directly. ## Exemplar Modules @@ -84,3 +84,4 @@ When in doubt, reference: - `infrastructure/modules/s3-bucket` – full wrapper with security table, locals-based naming - `infrastructure/modules/iam` – multi-resource wrapper with per-resource iteration and label modules - `infrastructure/modules/secrets-manager` – simple wrapper with hard-coded security +- `infrastructure/modules/acm` – simple wrapper with opinionated security defaults diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 13604b7d..8a9b1736 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -35,6 +35,7 @@ updates: # BEGIN_AUTOGENERATED_TERRAFORM_UPDATES - package-ecosystem: "terraform" directories: + - "infrastructure/modules/acm" - "infrastructure/modules/api-gateway" - "infrastructure/modules/aws-backup-destination" - "infrastructure/modules/aws-backup-source" diff --git a/.github/instructions/terraform-modules.instructions.md b/.github/instructions/terraform-modules.instructions.md index bee2bbb0..5c7a0bb6 100644 --- a/.github/instructions/terraform-modules.instructions.md +++ b/.github/instructions/terraform-modules.instructions.md @@ -117,15 +117,21 @@ See `.github/skills/pre-commit-hooks.skill.md` for detailed documentation on all Every module must contain: -| File | Purpose | -| --- | --- | -| `main.tf` | Primary resource definitions with header comment block | -| `variables.tf` | Input variables with types, descriptions, defaults, validations | -| `outputs.tf` | Output values with descriptions | -| `versions.tf` | `required_version` and `required_providers` | -| `context.tf` | Tags context (copied from `tags/exports/context.tf`) | -| `locals.tf` | Derived/computed values (naming, defaults) | -| `README.md` | Usage documentation with examples | +| File | Purpose | Mandatory | +| --- | --- | --- | +| `main.tf` | Primary resource definitions with header comment block | Yes | +| `variables.tf` | Input variables with types, descriptions, defaults, validations | Yes | +| `outputs.tf` | Output values with descriptions | Yes | +| `versions.tf` | `required_version` and `required_providers` | Yes | +| `context.tf` | Tags context (copied from `tags/exports/context.tf`) | Yes | +| `data.tf` | Data sources (e.g., `data.aws_*`, `data.local_file`) | Only if data sources exist | +| `locals.tf` | Derived/computed values (naming, defaults) | Only if `locals {}` blocks exist | +| `README.md` | Usage documentation with examples | Yes | + +**Conditional file guidance:** + +- **`data.tf`**: Create this file if the module queries external data (e.g., `data.aws_availability_zones`, `data.aws_ami`). Store all data sources here for clarity. +- **`locals.tf`**: Create this file only if the module defines `locals {}` blocks for computed values, naming logic, or CIDR calculations. If no locals are needed, omit the file entirely. For any newly created module, `context.tf` must come from `infrastructure/modules/tags/exports/context.tf`, and the copied file must reference `source = "../tags"`. @@ -192,7 +198,7 @@ Every module must enforce: - Encryption at rest (KMS or service-managed) where applicable. - Encryption in transit (TLS required, deny insecure transport) where applicable. - No public access by default (block at all available toggles). -- iam least-privilege (no `*` actions in managed policies). +- IAM least-privilege (no `*` actions in managed policies). - Logging/audit enabled where the service supports it. - All resources tagged via `module.this.tags`. @@ -302,3 +308,4 @@ When in doubt, look at these compliant modules for reference: - `infrastructure/modules/iam` — Multi-resource wrapper (policies + roles) with per-resource iteration and label modules. - `infrastructure/modules/secrets-manager` — Simple wrapper with hard-coded security and optional features. - `infrastructure/modules/kms` — KMS key wrapper with policy enforcement. +- `infrastructure/modules/acm` — Simple wrapper with opinionated security defaults. diff --git a/.github/prompts/new-terraform-module.prompt.md b/.github/prompts/new-terraform-module.prompt.md index f2bd1cd1..fcde431b 100644 --- a/.github/prompts/new-terraform-module.prompt.md +++ b/.github/prompts/new-terraform-module.prompt.md @@ -93,12 +93,16 @@ terraform { required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.42" + version = ">= {{ upstream_min_version }}" # Use the upstream module's minimum; >= 6.42 is the platform baseline } } } ``` +> **Note:** Use whichever is higher — the upstream community module's stated minimum +> provider version, or the platform baseline of `>= 6.42`. Do not blindly apply the +> platform baseline if the upstream module works correctly at a lower version. + ### 5. `context.tf` Copy from `infrastructure/modules/tags/exports/context.tf`: @@ -197,7 +201,7 @@ Before finalising, verify the module enforces: - [ ] Encryption at rest (KMS or service-managed) - [ ] Encryption in transit (TLS required) where applicable - [ ] No public access by default -- [ ] iam least-privilege (no `*` actions) +- [ ] IAM least-privilege (no `*` actions) - [ ] Logging enabled where the service supports it - [ ] All resources tagged via `module.this.tags` - [ ] Creation gated by `module.this.enabled` @@ -224,3 +228,4 @@ Reference these for patterns: - `infrastructure/modules/s3-bucket` — full wrapper with comprehensive security - `infrastructure/modules/iam` — multi-resource wrapper with per-resource iteration - `infrastructure/modules/secrets-manager` — simple wrapper with hard-coded security +- `infrastructure/modules/acm` – simple wrapper with opinionated security defaults diff --git a/.github/skills/pre-commit-hooks.skill.md b/.github/skills/pre-commit-hooks.skill.md index e6ac26d8..8cfcba03 100644 --- a/.github/skills/pre-commit-hooks.skill.md +++ b/.github/skills/pre-commit-hooks.skill.md @@ -390,7 +390,7 @@ AWS credentials detected **Fix:** Never commit credentials. Use: - GitHub Secrets for CI/CD -- AWS assume role or iam OIDC federation +- AWS assume role or IAM OIDC federation - `~/.aws/credentials` for local development --- diff --git a/.github/skills/terraform-module-maintenance.skill.md b/.github/skills/terraform-module-maintenance.skill.md index 09794a93..0cb2f019 100644 --- a/.github/skills/terraform-module-maintenance.skill.md +++ b/.github/skills/terraform-module-maintenance.skill.md @@ -111,6 +111,13 @@ terraform-docs markdown . > README.md Pre-commit hooks will validate that README.md is in sync with the module's variables/outputs on every commit. +## Module File Structure Rules + +- `data.tf` is required only if the module uses data sources. +- `locals.tf` is required only if the module defines `locals {}` blocks. +- If these constructs are not used, the corresponding file should be omitted. +- If used, centralise all data sources in `data.tf` and local values in `locals.tf`. + ## Compliance & Security Review After upgrading a module: @@ -118,7 +125,7 @@ After upgrading a module: 1. **Check security baseline**: Verify that enforcement controls haven't been weakened. 2. **Confirm encryption defaults**: Ensure encryption settings still use fixed values. 3. **Confirm public access blocks**: Ensure public access blocks are still enabled. -4. **Confirm iam permissions**: Keep iam policies minimal (no `*` actions). +4. **Confirm IAM permissions**: Keep IAM policies minimal (no `*` actions). 5. **Review upstream breaking changes**: Check community module release notes for incompatible API changes. 6. **Validate outputs**: Ensure stable output names are preserved; consumers depend on them. 7. **Test in context**: If possible, apply the module in a test stack to confirm integration. diff --git a/.github/skills/terraform-module-patterns.skill.md b/.github/skills/terraform-module-patterns.skill.md index c15b3d61..5c6f9e93 100644 --- a/.github/skills/terraform-module-patterns.skill.md +++ b/.github/skills/terraform-module-patterns.skill.md @@ -181,7 +181,13 @@ variable "meaningful_name" { - Complex types → use `object({})` with `optional()` fields. - Sensitive values → mark with `sensitive = true`. -## Locals Pattern +## Conditional Module Files + +- `data.tf` is mandatory only when data sources exist in the module (for example `data.aws_*`, `data.local_file`, `data.external`). +- `locals.tf` is mandatory only when the module defines one or more `locals {}` blocks. +- When present, keep all data sources in `data.tf` and all local values in `locals.tf`. + +## Locals Pattern (When Locals Are Needed) ```hcl ################################################################ @@ -284,7 +290,7 @@ module that enforces the platform's baseline controls. \```hcl module "example" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/?ref=" service = "bcss" environment = "test" diff --git a/AGENTS.md b/AGENTS.md index 01a1836a..0fbec2ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -79,7 +79,7 @@ Agents **must not**: When proposing a change, agents should: - Keep code formatted and idiomatic (Terraform HCL, Bash, YAML). -- Stick to existing patterns — look at compliant modules (`s3-bucket`, `iam`, `secrets-manager`, `kms`) as exemplars. +- Stick to existing patterns — look at compliant modules (`s3-bucket`, `iam`, `secrets-manager`, `kms`, `acm`) as exemplars. - **Run all pre-commit hooks before committing**: `pre-commit run --all-files` (see `.github/skills/pre-commit-hooks.skill.md` for details on each hook). - Run `terraform fmt -recursive` before committing. - Run `terraform validate` in affected module directories. @@ -94,7 +94,7 @@ Not all modules in this repository are currently compliant. The following tiers | Tier | Description | Examples | | --- | --- | --- | -| **Compliant** | Full wrapper pattern, `context.tf`, security baseline, proper variables/outputs/README | `s3-bucket`, `iam`, `secrets-manager`, `kms`, `sns` | +| **Compliant** | Full wrapper pattern, `context.tf`, security baseline, proper variables/outputs/README | `s3-bucket`, `iam`, `secrets-manager`, `kms`, `acm` | | **Partially compliant** | Has `context.tf` but may be missing validation, README, or security hardening | `lambda`, `ecr`, `vpc` | | **Legacy** | Older modules that predate the current conventions; may lack `context.tf` entirely | Various older modules | diff --git a/README.md b/README.md index 51b0acb3..5c446f4b 100644 --- a/README.md +++ b/README.md @@ -274,7 +274,7 @@ module "s3_bucket" { | Encryption at rest | KMS or service-managed; no unencrypted storage | | Encryption in transit | TLS required where applicable | | No public access | Blocked by default at all available toggles | -| iam least-privilege | No `*` actions in policies | +| IAM least-privilege | No `*` actions in policies | | Logging | Enabled where the service supports it | | Tagging | All resources via `module.this.tags` | @@ -297,40 +297,45 @@ Rules: | Module | Wraps | Description | | --- | --- | --- | -| `api-gateway` | — | API Gateway configuration | +| `acm` | terraform-aws-modules/acm/aws | AWS Certificate Manager (ACM) certificate management | +| `api-gateway` | — | API Gateway configuration with custom domain and integration | | `aws-backup-destination` | — | AWS Backup destination vault | | `aws-backup-source` | — | AWS Backup source configuration | -| `aws-scheduler` | — | EventBridge Scheduler | -| `cognito` | — | Cognito user/identity pools | -| `cw-firehose-splunk` | — | CloudWatch to Splunk via Firehose | -| `ecr` | — | ECR repository | +| `aws-scheduler` | — | EventBridge Scheduler configuration | +| `cognito` | — | Cognito user and identity pools | +| `cw-firehose-splunk` | — | CloudWatch logs to Splunk via Firehose | +| `ecr` | — | ECR repository with security controls | | `ecs-cluster` | — | ECS Fargate cluster | -| `elasticache` | — | ElastiCache cluster | -| `github-config` | — | GitHub OIDC and runner configuration | +| `ecs-service` | — | ECS service and task definition | +| `elasticache` | — | ElastiCache cluster (Redis/Memcached) | +| `github-config` | — | GitHub OIDC provider and runner configuration | | `guardduty` | — | GuardDuty threat detection | -| `iam` | `terraform-aws-modules/iam/aws` | iam policies and roles | +| `iam` | terraform-aws-modules/iam/aws | IAM policies and roles | | `inspector` | — | Inspector vulnerability scanning | -| `kms` | `terraform-aws-modules/kms/aws` | KMS key with policy enforcement | -| `lambda` | — | Lambda function | -| `lambda-layer` | — | Lambda layer | +| `kms` | terraform-aws-modules/kms/aws | KMS key with policy enforcement | +| `lambda` | terraform-aws-modules/lambda/aws | Lambda function with runtime and layers | +| `lambda-layer` | — | Lambda layer for function libraries | | `license-manager` | — | License Manager configuration | -| `parameter_store` | — | SSM Parameter Store | +| `network-firewall` | — | Network Firewall rules and policies | +| `parameter_store` | — | SSM Parameter Store configuration | +| `r53` | — | Route 53 DNS records (legacy) | | `r53-healthcheck` | — | Route 53 health checks | +| `rds` | — | RDS database instance (legacy) | | `rds-database` | — | RDS database (logical) | | `rds-gateway-ecs-task` | — | RDS gateway ECS task definition | | `rds-instance` | — | RDS instance | | `rds-users` | — | RDS user management | -| `s3` | — | S3 (legacy) | -| `s3-bucket` | `terraform-aws-modules/s3-bucket/aws` | S3 bucket with full security | -| `secrets-manager` | `terraform-aws-modules/secrets-manager/aws` | Secrets Manager | -| `security-hub` | — | Security Hub | -| `sns` | Native resources | SNS topic with encryption | -| `sqs` | — | SQS queue | -| `tags` | — | Foundation: naming and tagging context | -| `vpc` | — | VPC | -| `vpce` | — | VPC endpoint (single) | -| `vpces` | — | VPC endpoints (multiple) | -| `waf` | — | WAF web ACL | +| `s3` | — | S3 bucket (legacy) | +| `s3-bucket` | terraform-aws-modules/s3-bucket/aws | S3 bucket with full security baseline | +| `secrets-manager` | terraform-aws-modules/secrets-manager/aws | Secrets Manager for secure secret storage | +| `security-hub` | — | Security Hub for centralized security findings | +| `sns` | terraform-aws-modules/sns/aws | SNS topic with encryption and policies | +| `sqs` | — | SQS queue with encryption | +| `tags` | — | Foundation: naming and tagging context module | +| `vpc` | — | VPC with subnets, routing, and gateways | +| `vpce` | — | VPC endpoint (single service) | +| `vpces` | — | VPC endpoints (multiple services) | +| `waf` | — | WAF web ACL with rules | ## Pre-commit hooks diff --git a/docs/user-guides/Pre_commit_hooks_reference.md b/docs/user-guides/Pre_commit_hooks_reference.md index 55bf15ca..5d8eca97 100644 --- a/docs/user-guides/Pre_commit_hooks_reference.md +++ b/docs/user-guides/Pre_commit_hooks_reference.md @@ -520,7 +520,7 @@ AWS credentials detected 1. **Remove the credential immediately** 2. Use GitHub Secrets for CI/CD. -3. Use AWS iam assume role or OIDC federation. +3. Use AWS IAM assume role or OIDC federation. 4. Use `~/.aws/credentials` for local development (never commit). **Prevention:** Never paste real credentials anywhere. diff --git a/infrastructure/modules/acm/README.md b/infrastructure/modules/acm/README.md index 967f5f5a..5dfa7d9f 100644 --- a/infrastructure/modules/acm/README.md +++ b/infrastructure/modules/acm/README.md @@ -21,7 +21,7 @@ the shared `context.tf` for naming and tagging. ```hcl module "acm" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "portal" @@ -37,7 +37,7 @@ module "acm" { ```hcl module "acm" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "platform" @@ -61,7 +61,7 @@ module "acm" { ```hcl module "acm_private" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "internal" @@ -78,7 +78,7 @@ module "acm_private" { ```hcl module "acm_no_ct" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "portal" @@ -99,7 +99,7 @@ module "acm_no_ct" { ```hcl module "acm_validation_records" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/acm?ref=" service = "bcss" project = "platform" diff --git a/infrastructure/modules/acm/main.tf b/infrastructure/modules/acm/main.tf index e0ff9703..a4d50b8c 100644 --- a/infrastructure/modules/acm/main.tf +++ b/infrastructure/modules/acm/main.tf @@ -2,7 +2,7 @@ # AWS Certificate Manager (ACM) # # A thin wrapper around the terraform-aws-modules/acm/aws module, -# enforcing the following opinions: +# enforcing the following controls: # # * new certificates are validated via DNS, specifically via Route53 # * certificates cannot be exported diff --git a/infrastructure/modules/guardduty/README.md b/infrastructure/modules/guardduty/README.md index e0be2491..662fdafd 100644 --- a/infrastructure/modules/guardduty/README.md +++ b/infrastructure/modules/guardduty/README.md @@ -1,5 +1,110 @@ # GuardDuty +NHS Screening wrapper for AWS GuardDuty that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Threat detection | GuardDuty detector enabled when `enable_detector = true` | +| S3 protection | S3 Data Events monitoring enabled by default (`s3_protection_enabled = true`) | +| EBS malware scanning | EBS Malware Protection enabled by default (`malware_protection_scan_ec2_ebs_volumes_enabled = true`) | +| Finding notifications | CloudWatch Event rule forwards findings to SNS by default (`enable_cloudwatch = true`) | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | + +## Usage + +### Minimal detector with default protections + +```hcl +module "guardduty" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/guardduty?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "detector" + + enable_detector = true +} +``` + +### Production detector with all protections and SNS forwarding + +```hcl +module "guardduty" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/guardduty?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "detector" + + enable_detector = true + + # Protection features + s3_protection_enabled = true + malware_protection_scan_ec2_ebs_volumes_enabled = true + kubernetes_audit_logs_enabled = true + lambda_network_logs_enabled = true + runtime_monitoring_enabled = true + + runtime_monitoring_additional_config = { + eks_addon_management_enabled = true + ecs_fargate_agent_management_enabled = true + ec2_agent_management_enabled = true + } + + # Forward findings to SNS + enable_cloudwatch = true + findings_notification_arn = module.sns_alerts.topic_arn + finding_publishing_frequency = "FIFTEEN_MINUTES" +} +``` + +### Detector with minimal protections (non-production use) + +```hcl +module "guardduty_dev" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/guardduty?ref=" + + service = "bcss" + project = "security" + environment = "dev" + name = "detector" + + enable_detector = true + + # Minimal protections for cost control in dev + s3_protection_enabled = false + malware_protection_scan_ec2_ebs_volumes_enabled = false + kubernetes_audit_logs_enabled = false + lambda_network_logs_enabled = false + runtime_monitoring_enabled = false + + # Disable SNS forwarding in dev + enable_cloudwatch = false +} +``` + +## Conventions + +- `enable_detector` defaults to `false`; you must explicitly enable it. +- S3 protection (`s3_protection_enabled`) and EBS malware scanning (`malware_protection_scan_ec2_ebs_volumes_enabled`) default to `true`. +- Runtime monitoring features (EKS, ECS, Lambda) default to `false`; enable based on workload requirements. +- `runtime_monitoring_enabled` and `eks_runtime_monitoring_enabled` are mutually exclusive; `RUNTIME_MONITORING` already covers EKS. +- CloudWatch Event rule (`enable_cloudwatch`) defaults to `true` and creates a forwarding rule; provide `findings_notification_arn` to wire it to an SNS topic. +- `finding_publishing_frequency` defaults to `FIFTEEN_MINUTES` for faster detection; adjust to `ONE_HOUR` or `SIX_HOURS` if lower alert volume is acceptable. +- The EventBridge rule uses a separate context label (`findings`) so its name/tags are distinct from the detector. + +## What this module does NOT do + +- Create or manage SNS topics for findings; you must create the topic separately and pass its ARN via `findings_notification_arn`. +- Configure GuardDuty member accounts or delegated administrator relationships; this module manages standalone or master account detectors only. +- Export findings to S3 or other destinations; use AWS GuardDuty's native export configuration outside this module if required. +- Automatically enable runtime monitoring agents on EC2/ECS/EKS; the module enables the GuardDuty feature, but agent deployment is separate. + @@ -81,7 +186,7 @@ | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [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 this module path. | `string` | `null` | 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 | | [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/guardduty/context.tf b/infrastructure/modules/guardduty/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/guardduty/context.tf +++ b/infrastructure/modules/guardduty/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/iam/README.md b/infrastructure/modules/iam/README.md index 421d715c..d50dfb41 100644 --- a/infrastructure/modules/iam/README.md +++ b/infrastructure/modules/iam/README.md @@ -1,6 +1,6 @@ -# iam +# IAM -Creates iam customer-managed policies and (optionally) iam roles for any +Creates IAM customer-managed policies and (optionally) IAM roles for any team on the screening platform. Thin wrapper around the community [`terraform-aws-modules/iam/aws`](https://registry.terraform.io/modules/terraform-aws-modules/iam/aws/latest) submodules (`iam-policy` and `iam-role`), with naming and tagging supplied @@ -8,6 +8,16 @@ by the central `tags` module via `context.tf` — so every team gets consistent `///` paths and the standard NHS tag set automatically. +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| IAM path namespacing | Policies and roles use `///` path derived from context | +| Consistent naming | Names derived from `module.this.id` with per-resource attributes | +| Tagging | All policies and roles tagged via `module.this.tags` | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Map-driven interface | Single module call produces multiple policies/roles with stable keys | + ## Usage The module is map-driven — one invocation can produce many policies and @@ -15,7 +25,7 @@ roles. Three typical consumer patterns: ### 1. SSO customer-managed policies (no roles) -Use this when defining the iam policies that AWS Identity Center +Use this when defining the IAM policies that AWS Identity Center permission sets will reference. The SSO wiring itself (`aws_ssoadmin_permission_set`, `aws_ssoadmin_customer_managed_policy_attachment`, account assignments) lives in the consumer stack, not in this module. @@ -126,12 +136,12 @@ module "iam" { - **Naming.** Resource names are derived from `module.this.id` plus an `attributes` suffix — e.g. `-policy-` and `-role-`. -- **iam path.** Defaults to `///` from context. Override +- **IAM path.** Defaults to `///` from context. Override globally with `var.path` or per-entry with `entry.path`. - **Enabled switch.** Set `context.enabled = false` to disable the entire module (e.g. in development tfvars). All resources are gated by it. - **Descriptions.** Strongly encouraged on every policy and role — - whoever sees them in the iam console later will thank you. + whoever sees them in the IAM console later will thank you. - **Inline policies.** `inline_policies` is a map of name -> JSON document; the upstream `iam-role` submodule merges all documents into a single inline policy on the role, so the map keys are used only for caller-side @@ -142,8 +152,8 @@ module "iam" { - SSO permission sets, account assignments, group/user management — lives in the consumer stack via `aws_ssoadmin_*` and `aws_identitystore_*`. -- iam users, iam groups, SAML/OIDC identity providers -- Account-wide iam settings (password policy, account alias, MFA enforcement). +- IAM users, IAM groups, SAML/OIDC identity providers +- Account-wide IAM settings (password policy, account alias, MFA enforcement). @@ -208,7 +218,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [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 this module path. | `string` | `null` | 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 | | [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/iam/context.tf b/infrastructure/modules/iam/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/iam/context.tf +++ b/infrastructure/modules/iam/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/iam/locals.tf b/infrastructure/modules/iam/locals.tf new file mode 100644 index 00000000..9146cae2 --- /dev/null +++ b/infrastructure/modules/iam/locals.tf @@ -0,0 +1,17 @@ +locals { + # Default IAM path falls back to a context-derived value so policies/roles + # are grouped under a predictable namespace, e.g. "/bcss/screening/". + default_iam_path = format( + "/%s/", + join("/", compact([module.this.service, module.this.project, module.this.environment])) + ) + iam_path = var.path != null ? var.path : local.default_iam_path + + # role_key -> { static_name => policy_arn } for attached policies. + role_policies = { + for role_key, role in var.roles : role_key => merge( + { for idx, arn in role.policy_arns : "external-${idx}" => arn }, + { for k in role.policy_keys : k => module.policies[k].arn } + ) + } +} diff --git a/infrastructure/modules/iam/main.tf b/infrastructure/modules/iam/main.tf index f878c0f8..2c1e3b84 100644 --- a/infrastructure/modules/iam/main.tf +++ b/infrastructure/modules/iam/main.tf @@ -10,16 +10,6 @@ # so this wrapper invokes the relevant submodules ################################################################ -locals { - # Default IAM path falls back to a context-derived value so policies/roles - # are grouped under a predictable namespace, e.g. "/bcss/screening/". - default_iam_path = format( - "/%s/", - join("/", compact([module.this.service, module.this.project, module.this.environment])) - ) - iam_path = var.path != null ? var.path : local.default_iam_path -} - ################################################################ # Per-policy and per-role label modules # @@ -71,16 +61,6 @@ module "policies" { # so all statements are merged into one inline policy per role. ################################################################ -locals { - # role_key -> { static_name => policy_arn } for attached policies. - role_policies = { - for role_key, role in var.roles : role_key => merge( - { for idx, arn in role.policy_arns : "external-${idx}" => arn }, - { for k in role.policy_keys : k => module.policies[k].arn } - ) - } -} - module "roles" { source = "terraform-aws-modules/iam/aws//modules/iam-role" version = "6.6.0" diff --git a/infrastructure/modules/inspector/README.md b/infrastructure/modules/inspector/README.md index ad67f62f..a2301dd2 100644 --- a/infrastructure/modules/inspector/README.md +++ b/infrastructure/modules/inspector/README.md @@ -8,6 +8,115 @@ Classic with consistent naming and tagging via the shared For Inspector v2 (continuous scanning of EC2, ECR and Lambda), build a separate module using the `aws_inspector2_*` resources. +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Periodic assessments | CloudWatch Event rule triggers Inspector on a schedule (`schedule_expression`) | +| Rule package validation | Only valid short identifiers (`cve`, `cis`, `nr`, `sbp`) are accepted | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | + +## Usage + +### Minimal Inspector Classic with CVE and CIS rules + +```hcl +module "inspector" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/inspector?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "classic" + + enabled_rules = ["cve", "cis"] +} +``` + +### Production Inspector with all rule packages and SNS notifications + +```hcl +module "inspector" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/inspector?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "full-scan" + + enabled_rules = ["cve", "cis", "nr", "sbp"] + + # Run assessments daily + schedule_expression = "rate(1 day)" + assessment_duration = "7200" + + # Send notifications to SNS + assessment_event_subscription = { + completed = { + event = "ASSESSMENT_RUN_COMPLETED" + topic_arn = module.sns_alerts.topic_arn + } + failed = { + event = "ASSESSMENT_RUN_STATE_CHANGED" + topic_arn = module.sns_alerts.topic_arn + } + } +} +``` + +### Custom IAM role for Inspector execution + +```hcl +resource "aws_iam_role" "inspector" { + name = "custom-inspector-role" + + assume_role_policy = jsonencode({ + Version = "2012-10-17" + Statement = [{ + Effect = "Allow" + Principal = { Service = "events.amazonaws.com" } + Action = "sts:AssumeRole" + }] + }) +} + +resource "aws_iam_role_policy_attachment" "inspector" { + role = aws_iam_role.inspector.name + policy_arn = "arn:aws:iam::aws:policy/service-role/AmazonInspectorServiceRolePolicy" +} + +module "inspector" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/inspector?ref=" + + service = "bcss" + project = "security" + environment = "prod" + name = "custom-role" + + enabled_rules = ["cve", "cis"] + + create_iam_role = false + iam_role_arn = aws_iam_role.inspector.arn +} +``` + +## Conventions + +- `enabled_rules` is required and must contain at least one valid rule package identifier. +- Valid short identifiers: `cve` (Common Vulnerabilities & Exposures), `cis` (CIS benchmarks), `nr` (Network Reachability), `sbp` (Security Best Practices). +- `schedule_expression` defaults to `rate(7 days)`; adjust based on compliance requirements. +- `assessment_duration` defaults to `3600` seconds (1 hour); increase for larger environments. +- `create_iam_role` defaults to `false`; the upstream module creates a role if set to `true`, or you can provide an existing role via `iam_role_arn`. +- `assessment_event_subscription` is a map for stability; use descriptive keys like `completed`, `failed`, `started`. + +## What this module does NOT do + +- Support Inspector v2 (use native `aws_inspector2_*` resources for that). +- Create SNS topics for notifications; you must create the topic separately and pass its ARN. +- Install or configure the Inspector agent on EC2 instances; that is managed separately via Systems Manager or user data scripts. +- Support cross-account or organisation-wide Inspector delegation; this module manages standalone account deployments only. + @@ -72,7 +181,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [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 this module path. | `string` | `null` | 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 | | [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/inspector/context.tf b/infrastructure/modules/inspector/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/inspector/context.tf +++ b/infrastructure/modules/inspector/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/kms/README.md b/infrastructure/modules/kms/README.md index c7903b40..f42f2674 100644 --- a/infrastructure/modules/kms/README.md +++ b/infrastructure/modules/kms/README.md @@ -1,29 +1,103 @@ # AWS KMS Terraform module -Terraform module to provision a [KMS](https://aws.amazon.com/kms/) key with alias. +Terraform module to provision a [KMS](https://aws.amazon.com/kms/) key with alias. Thin wrapper around the community [`terraform-aws-modules/kms/aws`](https://registry.terraform.io/modules/terraform-aws-modules/kms/aws/latest) module that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Key rotation | Automatic key rotation enabled by default (`enable_key_rotation = true`) | +| Deletion protection | 14-day deletion window by default (`deletion_window_in_days = 14`) | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Symmetric encryption | Defaults to `SYMMETRIC_DEFAULT` key spec for encryption use cases | ## Usage +### Minimal KMS key with default settings + +```hcl +module "kms_key" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/kms?ref=" + + service = "bcss" + project = "data" + environment = "prod" + name = "application-secrets" + + description = "KMS key for application secrets encryption" +} +``` + +### Production KMS key with custom policies + ```hcl - module "kms_key" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/kms" - - service = "bcss" - project = "bcss" - environment = "test" - stack = "bootstrap" - workspace = terraform.workspace - name = "terraform-state" - - label_order = ["service", "environment", "stack", "workspace", "name", "attributes"] - - description = "KMS key for Terraform state bucket encryption" - deletion_window_in_days = 10 - enable_key_rotation = true - } +module "kms_key" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/kms?ref=" + + service = "bcss" + project = "bcss" + environment = "prod" + stack = "bootstrap" + name = "terraform-state" + + label_order = ["service", "environment", "stack", "workspace", "name", "attributes"] + + description = "KMS key for Terraform state bucket encryption" + deletion_window_in_days = 30 + enable_key_rotation = true + + key_administrators = [ + "arn:aws:iam::123456789012:role/admin-role" + ] + + key_users = [ + "arn:aws:iam::123456789012:role/ecs-task-role", + "arn:aws:iam::123456789012:role/lambda-execution-role" + ] +} ``` +### Asymmetric key for signing + +```hcl +module "kms_signing_key" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/kms?ref=" + + service = "bcss" + project = "signing" + environment = "prod" + name = "code-signing" + + description = "KMS key for code signing" + customer_master_key_spec = "RSA_4096" + key_usage = "SIGN_VERIFY" + enable_key_rotation = false # Not supported for asymmetric keys + + key_users = [ + "arn:aws:iam::123456789012:role/ci-cd-role" + ] +} +``` + +## Conventions + +- `enable_key_rotation` defaults to `true` for symmetric keys; automatic rotation is not supported for asymmetric keys. +- `deletion_window_in_days` defaults to `14`; increase to 30 for production keys to allow for recovery. +- `customer_master_key_spec` defaults to `SYMMETRIC_DEFAULT`; use `RSA_*` or `ECC_*` specs for asymmetric encryption or signing. +- `key_usage` defaults to `ENCRYPT_DECRYPT`; set to `SIGN_VERIFY` for signing keys. +- `aliases` can be specified to create custom key aliases; if not provided, the alias is auto-generated from `module.this.id`. +- `key_administrators`, `key_users`, and `key_service_users` are all optional IAM ARN lists that control key policy permissions. +- `enable_default_policy` defaults to `true`; set to `false` only if providing a complete custom policy via `key_statements`. + +## What this module does NOT do + +- Create IAM roles or users; you must provide existing ARNs for key administrators/users. +- Encrypt or decrypt data; the module creates the key, but encryption operations are performed by services/applications. +- Support multi-region keys (currently uses single-region keys only). +- Manage key grants (use `aws_kms_grant` resources directly in consumer stacks if needed). + @@ -95,7 +169,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [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 this module path. | `string` | `null` | 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 | | [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/kms/context.tf b/infrastructure/modules/kms/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/kms/context.tf +++ b/infrastructure/modules/kms/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/lambda/README.md b/infrastructure/modules/lambda/README.md index 24dedb23..88884d64 100644 --- a/infrastructure/modules/lambda/README.md +++ b/infrastructure/modules/lambda/README.md @@ -1,5 +1,114 @@ # Lambda +NHS Screening wrapper around the community [`terraform-aws-modules/lambda/aws`](https://registry.terraform.io/modules/terraform-aws-modules/lambda/aws/latest) module that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| IAM execution role | Automatically attaches AWS-managed policies for VPC access, CloudWatch Logs, and SQS execution | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Source path convention | Defaults to `../../lambdas//` for consistent repo layout | + +## Usage + +### Minimal Lambda function + +```hcl +module "lambda_processor" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/lambda?ref=" + + service = "bcss" + project = "data" + environment = "prod" + name = "processor" + + handler_prefix = "process_records" + function_description = "Process incoming data records from SQS" + python_version = "python3.12" +} +``` + +### Lambda with VPC and environment variables + +```hcl +module "lambda_api" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/lambda?ref=" + + service = "bcss" + project = "api" + environment = "prod" + name = "handler" + + handler_prefix = "api_handler" + function_description = "API Gateway Lambda handler" + python_version = "python3.12" + timeout = 30 + + vpc_subnet_ids = module.vpc.private_subnet_ids + vpc_security_group_ids = [module.security_group.id] + + environment_variables = { + DB_ENDPOINT = module.rds.endpoint + REGION = "eu-west-2" + LOG_LEVEL = "INFO" + } +} +``` + +### Lambda with layers and custom source path + +```hcl +module "lambda_with_layers" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/lambda?ref=" + + service = "bcss" + project = "etl" + environment = "prod" + name = "transformer" + + handler_prefix = "transform_data" + function_description = "Transform data using shared utility layers" + python_version = "python3.12" + source_path = "${path.module}/../../lambdas/custom/transform_data/" + timeout = 120 + + layers = [ + "arn:aws:lambda:eu-west-2:123456789012:layer:shared-utils:5", + "arn:aws:lambda:eu-west-2:123456789012:layer:pandas:2" + ] + + environment_variables = { + BUCKET_NAME = module.s3.bucket_name + } +} +``` + +## Conventions + +- `handler_prefix` is required and determines the Lambda handler entry point (`.lambda_handler`). +- `function_description` is required for documentation and compliance. +- `python_version` defaults to `python3.11`; explicitly set it to pin runtime version. +- `source_path` defaults to `../../lambdas//` relative to the module; override for custom layouts. +- `timeout` defaults to `3` seconds; increase for long-running functions. +- The module automatically attaches four AWS-managed IAM policies: + - `AWSLambdaVPCAccessExecutionRole` (VPC networking) + - `AWSLambdaBasicExecutionRole` (CloudWatch Logs) + - `AmazonAPIGatewayPushToCloudWatchLogs` (API Gateway integration) + - `AWSLambdaSQSQueueExecutionRole` (SQS polling) +- Use the `layers` input to attach Lambda layers; provide full ARNs including version. +- `vpc_subnet_ids` and `vpc_security_group_ids` are optional; omit for non-VPC functions. + +## What this module does NOT do + +- Create VPCs, subnets, or security groups; you must provide existing resource IDs. +- Package Lambda code; use the `source_path` to point to pre-packaged code or let the upstream module handle packaging. +- Create Lambda layers; you must create layers separately and provide ARNs. +- Configure Lambda event sources (SQS, EventBridge, S3, etc.); use native `aws_lambda_event_source_mapping` or trigger resources in consumer stacks. +- Manage Lambda function versions or aliases; use native `aws_lambda_alias` resources if needed. +- Provide custom IAM policies beyond the four AWS-managed policies; use the `iam` module to create custom policies and attach them separately. + @@ -71,7 +180,7 @@ | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [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 this module path. | `string` | `null` | 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 | | [timeout](#input\_timeout) | Timeout for the Lambda function in seconds | `number` | `120` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | | [vpc\_security\_group\_ids](#input\_vpc\_security\_group\_ids) | List of VPC security group IDs for the Lambda function | `list(string)` | `[]` | no | diff --git a/infrastructure/modules/lambda/context.tf b/infrastructure/modules/lambda/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/lambda/context.tf +++ b/infrastructure/modules/lambda/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/license-manager/README.md b/infrastructure/modules/license-manager/README.md index 7ef5a0fe..68ad03e0 100644 --- a/infrastructure/modules/license-manager/README.md +++ b/infrastructure/modules/license-manager/README.md @@ -1,5 +1,99 @@ # License Manager +NHS Screening module for AWS License Manager license configurations. Creates self-managed license configurations (vCPU / Core / Socket / Instance counted) with optional hard limits and resource associations. Uses the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| License counting validation | Only valid counting types (`vCPU`, `Instance`, `Core`, `Socket`) are accepted | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Stable associations | Map-based `for_each` prevents unnecessary re-creation of resource associations | + +## Usage + +### Minimal license configuration (vCPU counted) + +```hcl +module "license_manager" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/license-manager?ref=" + + service = "bcss" + project = "platform" + environment = "prod" + name = "windows-server" + + description = "Windows Server BYOL licenses" + license_counting_type = "vCPU" + license_count = 100 +} +``` + +### License configuration with hard limit and rules + +```hcl +module "license_sql" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/license-manager?ref=" + + service = "bcss" + project = "database" + environment = "prod" + name = "sql-server-enterprise" + + description = "SQL Server Enterprise BYOL licenses" + license_counting_type = "Core" + license_count = 64 + license_count_hard_limit = true + + license_rules = [ + "#minimumCores=4", + "#allowedTenancy=EC2-DedicatedHost" + ] +} +``` + +### License configuration with AMI associations + +```hcl +module "license_windows_with_amis" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/license-manager?ref=" + + service = "bcss" + project = "compute" + environment = "prod" + name = "windows-2022-byol" + + description = "Windows Server 2022 BYOL licenses" + license_counting_type = "Instance" + license_count = 50 + + associated_resource_arns = { + windows-2022-base = "arn:aws:ec2:eu-west-2::image/ami-0123456789abcdef0" + windows-2022-iis = "arn:aws:ec2:eu-west-2::image/ami-0fedcba9876543210" + windows-2022-sql-web = "arn:aws:ec2:eu-west-2::image/ami-01234567890fedcba" + } +} +``` + +## Conventions + +- `license_counting_type` is required and must be one of `vCPU`, `Instance`, `Core`, or `Socket`. +- `license_count` is optional; when null, no count limit is tracked. +- `license_count_hard_limit` defaults to `false`; set to `true` to block further usage once the count is exceeded. +- `license_rules` is an optional list of rules in the format `#RuleType=RuleValue`; supported rule types include `minimumVcpus`, `maximumVcpus`, `minimumCores`, `maximumCores`, `minimumSockets`, `maximumSockets`, and `allowedTenancy`. +- `associated_resource_arns` is a map where keys are stable identifiers (e.g., `windows-ami-2022`) and values are ARNs of AMIs, EC2 instances, hosts, or other supported resources. Map-based approach prevents unnecessary re-creation when associations change. +- `name_override` can be used to provide a custom name; when null, the name is derived from `module.this.id`. +- **Important:** Removing `license_count` after it has been set is not supported by the License Manager API and requires resource replacement. + +## What this module does NOT do + +- Create AMIs, EC2 instances, or dedicated hosts; you must provide existing resource ARNs for association. +- Support product licenses from AWS Marketplace (use the License Manager console or native resources for those). +- Manage license grants across AWS Organisations or delegated administrator accounts (this module is for standalone account configurations only). +- Enforce license usage automatically on EC2 instance launch; License Manager tracking is passive unless you configure enforcement rules separately. +- Support license configurations for third-party License Manager integrations (e.g., bring-your-own-license agreements outside AWS). + @@ -68,7 +162,7 @@ | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [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 this module path. | `string` | `null` | 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 | | [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/license-manager/context.tf b/infrastructure/modules/license-manager/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/license-manager/context.tf +++ b/infrastructure/modules/license-manager/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/license-manager/locals.tf b/infrastructure/modules/license-manager/locals.tf new file mode 100644 index 00000000..e057cf79 --- /dev/null +++ b/infrastructure/modules/license-manager/locals.tf @@ -0,0 +1,6 @@ +locals { + # Allow callers to override the generated name. Fall back to the + # context-derived id so naming stays consistent with sibling + # modules. + license_configuration_name = coalesce(var.name_override, module.this.id) +} diff --git a/infrastructure/modules/license-manager/main.tf b/infrastructure/modules/license-manager/main.tf index bc6b030f..b397ddd0 100644 --- a/infrastructure/modules/license-manager/main.tf +++ b/infrastructure/modules/license-manager/main.tf @@ -11,13 +11,6 @@ # screening service stacks and accounts. ################################################################ -locals { - # Allow callers to override the generated name. Fall back to the - # context-derived id so naming stays consistent with sibling - # modules. - license_configuration_name = coalesce(var.name_override, module.this.id) -} - ################################################################ # License configuration ################################################################ diff --git a/infrastructure/modules/s3-bucket/README.md b/infrastructure/modules/s3-bucket/README.md index 4de73540..eb41aabe 100644 --- a/infrastructure/modules/s3-bucket/README.md +++ b/infrastructure/modules/s3-bucket/README.md @@ -24,7 +24,7 @@ the shared `context.tf` for naming and tagging. ```hcl module "data_bucket" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=" service = "bcss" project = "ingest" @@ -37,7 +37,7 @@ module "data_bucket" { ```hcl module "audit_bucket" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=" service = "bcss" project = "audit" @@ -57,7 +57,7 @@ module "audit_bucket" { ```hcl module "log_bucket" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/s3?ref=" service = "bcss" project = "platform" @@ -155,7 +155,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [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 this module path. | `string` | `null` | 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 | | [versioning\_enabled](#input\_versioning\_enabled) | Whether object versioning is enabled. | `bool` | `true` | 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/s3-bucket/context.tf b/infrastructure/modules/s3-bucket/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/s3-bucket/context.tf +++ b/infrastructure/modules/s3-bucket/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/secrets-manager/README.md b/infrastructure/modules/secrets-manager/README.md index 02a80ae4..df6f7eeb 100644 --- a/infrastructure/modules/secrets-manager/README.md +++ b/infrastructure/modules/secrets-manager/README.md @@ -14,7 +14,7 @@ Thin NHS wrapper around [terraform-aws-modules/secrets-manager/aws](https://regi ```hcl module "db_credentials" { - source = "../../modules/secrets-manager" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref=" context = module.this.context stack = "database" @@ -36,7 +36,7 @@ module "db_credentials" { ```hcl module "api_key" { - source = "../../modules/secrets-manager" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref=" context = module.this.context stack = "api" @@ -65,7 +65,7 @@ module "api_key" { ```hcl module "rotated_password" { - source = "../../modules/secrets-manager" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/secrets-manager?ref=" context = module.this.context stack = "database" @@ -82,6 +82,25 @@ module "rotated_password" { } ``` +## Conventions + +- `block_public_policy` is always `true` and cannot be overridden — public access to secrets is never permitted. +- `recovery_window_in_days` defaults to `30`; set to `0` for immediate deletion (not recommended for production). +- `kms_key_id` is optional; when null, AWS uses the default `aws/secretsmanager` key. +- `secret_string` is the plaintext secret value (as a string or JSON-encoded object); use Terraform variables and `sensitive = true` to avoid exposure. +- `ignore_secret_changes` should be set to `true` when enabling rotation so Terraform does not overwrite the rotated value on the next apply. +- `create_random_password` generates a random password as the secret value; do not set `secret_string` when using this option. +- `create_policy` defaults to `false`; set to `true` and provide `policy_statements` to attach a resource-based policy. +- Secret names are derived from `module.this.id` for consistency with other screening modules. + +## What this module does NOT do + +- Create KMS keys; you must provide an existing key ARN via `kms_key_id` or accept the default AWS-managed key. +- Create rotation Lambda functions; you must create the function separately and provide its ARN via `rotation_lambda_arn`. +- Populate secret values automatically; you must provide `secret_string` or enable `create_random_password`. +- Manage secret replicas across regions (use native `aws_secretsmanager_secret_rotation` resources if multi-region replication is required). +- Retrieve secret values; use `data.aws_secretsmanager_secret_version` in consumer stacks to read secrets. + @@ -154,7 +173,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [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 this module path. | `string` | `null` | 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 | | [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/secrets-manager/context.tf b/infrastructure/modules/secrets-manager/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/secrets-manager/context.tf +++ b/infrastructure/modules/secrets-manager/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/security-hub/README.md b/infrastructure/modules/security-hub/README.md index 01e4ea6f..2e77f4e4 100644 --- a/infrastructure/modules/security-hub/README.md +++ b/infrastructure/modules/security-hub/README.md @@ -16,7 +16,7 @@ This wraps the upstream module in the same way as | Consistent naming & tagging | `context = module.this.context` forwarded to the upstream module | | `enabled` switch | Honoured via `module.this.context.enabled` | | Default standards on by default | `var.enable_default_standards = true` (AWS FSBP + CIS AWS Foundations) | -| Single source of SNS truth | `create_sns_topic = false`; findings forwarded to an existing topic arn | +| Single source of SNS truth | `create_sns_topic = false`; findings forwarded to an existing topic ARN | ## Pairing with GuardDuty @@ -32,7 +32,7 @@ shared SNS topic created by the separate alerting module via the ```hcl module "security_hub" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=" service = "bcss" project = "platform" @@ -45,7 +45,7 @@ module "security_hub" { ```hcl module "security_hub" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=" service = "bcss" project = "platform" @@ -64,7 +64,7 @@ module "security_hub" { ```hcl module "security_hub" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/security-hub?ref=" service = "bcss" project = "platform" @@ -75,10 +75,19 @@ module "security_hub" { } ``` +## Conventions + +* `enable_default_standards` defaults to `true`, enabling AWS Foundational Security Best Practices (FSBP) and CIS AWS Foundations Benchmark. +* `enabled_standards` is an optional list of additional standards to enable (e.g., `"standards/pci-dss/v/3.2.1"`). +* `finding_aggregator_enabled` defaults to `false`; set to `true` to aggregate findings from multiple regions into the current region. +* `create_sns_topic` is always `false` (the module does not create its own topic); provide `findings_notification_arn` to forward findings to an existing SNS topic. +* GuardDuty findings are automatically ingested by Security Hub when both services are enabled in the same account/region; no additional configuration is required. +* Context-based naming and tagging via `module.this.context` is forwarded to the upstream CloudPosse module. + ## What this module does NOT do * Create the SNS topic that receives findings. That is owned by the alerting - module — pass its arn via `findings_notification_arn`. + module — pass its ARN via `findings_notification_arn`. * Create a KMS key. If the alerting SNS topic is KMS-encrypted, configure that inside the alerting module. * Manage Organization-wide Security Hub administration / member accounts. Those @@ -148,7 +157,7 @@ No resources. | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | | [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 this module path. | `string` | `null` | 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 | | [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/security-hub/context.tf b/infrastructure/modules/security-hub/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/security-hub/context.tf +++ b/infrastructure/modules/security-hub/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/sns/README.md b/infrastructure/modules/sns/README.md index 3e7ce74d..fb39ca09 100644 --- a/infrastructure/modules/sns/README.md +++ b/infrastructure/modules/sns/README.md @@ -1,5 +1,94 @@ # SNS +NHS Screening wrapper around the community [`terraform-aws-modules/sns/aws`](https://registry.terraform.io/modules/terraform-aws-modules/sns/aws/latest) module that enforces the platform's baseline controls and consumes the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Service publish permissions | Pre-configured topic policy allows EventBridge, ElastiCache, Backup, and ECS services to publish | +| Tagging and naming | Uses shared `context.tf` (`module.this`) for tags and naming | +| Resource enable/disable | Creation gated by `module.this.enabled` | +| Standard (non-FIFO) topics | FIFO topics are disabled (`fifo_topic = false`) | + +## Usage + +### Minimal SNS topic + +```hcl +module "sns_alerts" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/sns?ref=" + + service = "bcss" + project = "platform" + environment = "prod" + name = "alerts" + aws_account_id = "123456789012" +} +``` + +### SNS topic with email subscription + +```hcl +module "sns_notifications" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/sns?ref=" + + service = "bcss" + project = "monitoring" + environment = "prod" + name = "notifications" + aws_account_id = "123456789012" + + subscriptions = { + email_team = { + protocol = "email" + endpoint = "team@example.nhs.uk" + } + } +} +``` + +### SNS topic with Lambda subscription and filter policy + +```hcl +module "sns_events" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/sns?ref=" + + service = "bcss" + project = "events" + environment = "prod" + name = "processor" + aws_account_id = "123456789012" + + subscriptions = { + lambda_processor = { + protocol = "lambda" + endpoint = module.lambda.function_arn + filter_policy = jsonencode({ + eventType = ["order.created", "order.updated"] + }) + } + } +} +``` + +## Conventions + +- `aws_account_id` is required for topic policy conditions (retained for compatibility with legacy stacks). +- `topic_name` can be used to override the context-derived name; when null, the name is derived from `module.this.id`. +- `fifo_topic` is always `false` (FIFO topics are not supported by this wrapper). +- `content_based_deduplication` is always `false` (relevant only for FIFO topics). +- The module pre-configures a topic policy allowing EventBridge, ElastiCache, AWS Backup, and ECS services to publish; these defaults preserve legacy behaviour and may be refined in future versions. +- `ecs_role_prefix` defaults to the topic name; set this explicitly if your ECS task roles use a different prefix. +- `subscriptions` is a map where keys are stable identifiers (e.g., `email_team`, `lambda_processor`) and values are subscription configurations. + +## What this module does NOT do + +- Create Lambda functions, SQS queues, or other endpoints; you must create those separately and provide ARNs/endpoints for subscriptions. +- Support FIFO topics (use native `aws_sns_topic` resources if FIFO is required). +- Manage cross-account publish permissions beyond the pre-configured service principals; use additional topic policy statements if cross-account access is needed. +- Automatically confirm email subscriptions; AWS sends a confirmation email that must be manually confirmed. + @@ -64,7 +153,7 @@ | [subscriptions](#input\_subscriptions) | Map of SNS subscriptions to create (passed through to terraform-aws-modules/sns/aws). |
map(object({
confirmation_timeout_in_minutes = optional(number)
delivery_policy = optional(string)
endpoint = string
endpoint_auto_confirms = optional(bool)
filter_policy = optional(string)
filter_policy_scope = optional(string)
protocol = string
raw_message_delivery = optional(bool)
redrive_policy = optional(string)
replay_policy = optional(string)
subscription_role_arn = optional(string)
}))
| `{}` | no | | [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 this module path. | `string` | `null` | 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 | | [topic\_name](#input\_topic\_name) | SNS topic name override. If null, the module derives a name from context (`module.this.id`). | `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/sns/context.tf b/infrastructure/modules/sns/context.tf index 62befcb0..e934a84f 100644 --- a/infrastructure/modules/sns/context.tf +++ b/infrastructure/modules/sns/context.tf @@ -113,7 +113,7 @@ variable "context" { variable "terraform_source" { type = string default = null - description = "Source location to record in the Terraform_source tag. Defaults to this module path." + description = "Source location to record in the Terraform_source tag. Defaults to the caller module path when not set." } variable "enabled" { diff --git a/infrastructure/modules/tags/README.md b/infrastructure/modules/tags/README.md index 43fb42aa..515f9189 100644 --- a/infrastructure/modules/tags/README.md +++ b/infrastructure/modules/tags/README.md @@ -54,7 +54,7 @@ The recommended convention is to use labels as follows: - `service`: A short (3-4 letters) abbreviation of the service directorate to ensure globally unique IDs for things like S3 buckets i.e. bcss - `project`: The name or role of the project the resource is for, such as `web` or `api` -- `region`: By default this will auto-populate the provider region, but can be overridden or set to `gbl` for resources like iam roles that have no region +- `region`: By default this will auto-populate the provider region, but can be overridden or set to `gbl` for resources like IAM roles that have no region - `environment`: The name or role of the account the resource is for, such as `prod` or `dev` - `workspace`: _(Rarely needed)_ Typically, the singular environment label suffices as there would only be a singular resource created per environment. On occasion, there may be multiple sub-environment, still of a singular environment/with shared environment resources i.e. sit1, sit2, nft1, nft2). `workspace` can be used to identify the specific sub-environment the resources relate to and by default is auto-populated to the `terraform.workspace` value. - `name`: The name of the component that owns the resources, such as `eks` or `rds` diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index 856dde19..1e45aa2a 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -7,7 +7,7 @@ account_id actionlint api_gateway_name api_path_part -arn +(ARN|[Aa]rn) backup_copy_vault_account_id backup_vault_name batch_id @@ -42,8 +42,8 @@ handler_prefix healthcheck http_method idempotence -iam -itterate +(IAM|[Ii]am) +[Ii]ntra Jira jq jQuery @@ -63,6 +63,7 @@ Octokit onboarding [Oo]pen[Ii][Dd] [Oo]utcode +plaintext Podman preprod prewritten @@ -71,6 +72,7 @@ printFooter printHeader Python rbac_role +[Rr]eachability readonly recovery_window repo @@ -85,7 +87,7 @@ shortcode source_account_name stage_name start_time -subnet +[Ss]ubnet Syft [Mm]arkdownlint [Tt]eardown @@ -101,5 +103,6 @@ URL url vault_lock_type vault_name +(VPC|[Vv]pc) [Uu][Uu][Ii][Dd] yq From c6e6ce11ea1592ea221bbf2185fb6dc91d3cab97 Mon Sep 17 00:00:00 2001 From: DeepikaDK <118273562+DeepikaDK001@users.noreply.github.com> Date: Tue, 23 Jun 2026 13:07:08 +0100 Subject: [PATCH 035/118] Added doc format fixes --- .github/dependabot.yaml | 1 + .gitleaksignore | 4 + .../modules/r53/.terraform.lock.hcl | 30 ++ infrastructure/modules/r53/README.md | 279 +++++++++++++ infrastructure/modules/r53/context.tf | 377 ++++++++++++++++++ infrastructure/modules/r53/locals.tf | 42 ++ infrastructure/modules/r53/main.tf | 200 ++++++++++ infrastructure/modules/r53/outputs.tf | 94 +++++ infrastructure/modules/r53/variables.tf | 164 ++++++++ infrastructure/modules/r53/versions.tf | 10 + scripts/config/gitleaks.toml | 27 +- 11 files changed, 1226 insertions(+), 2 deletions(-) create mode 100644 infrastructure/modules/r53/.terraform.lock.hcl create mode 100644 infrastructure/modules/r53/README.md create mode 100644 infrastructure/modules/r53/context.tf create mode 100644 infrastructure/modules/r53/locals.tf create mode 100644 infrastructure/modules/r53/main.tf create mode 100644 infrastructure/modules/r53/outputs.tf create mode 100644 infrastructure/modules/r53/variables.tf create mode 100644 infrastructure/modules/r53/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 8a9b1736..e1e87788 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -55,6 +55,7 @@ updates: - "infrastructure/modules/license-manager" - "infrastructure/modules/parameter_store" - "infrastructure/modules/r53-healthcheck" + - "infrastructure/modules/r53" - "infrastructure/modules/rds-database" - "infrastructure/modules/rds-gateway-ecs-task" - "infrastructure/modules/rds-instance" 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/infrastructure/modules/r53/.terraform.lock.hcl b/infrastructure/modules/r53/.terraform.lock.hcl new file mode 100644 index 00000000..10c97fd0 --- /dev/null +++ b/infrastructure/modules/r53/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.0.0, >= 6.14.0, >= 6.28.0, >= 6.42.0" + hashes = [ + "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:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} diff --git a/infrastructure/modules/r53/README.md b/infrastructure/modules/r53/README.md new file mode 100644 index 00000000..882d3d59 --- /dev/null +++ b/infrastructure/modules/r53/README.md @@ -0,0 +1,279 @@ +# r53 + +Creates Route53 hosted zones, Route53 Resolver endpoints, and Route53 +Resolver DNS Firewall rule groups for screening shared-resource stacks. +This is a thin screening wrapper around the community +[`terraform-aws-modules/route53/aws`](https://registry.terraform.io/modules/terraform-aws-modules/route53/aws/latest) +module set, with naming and tagging supplied by the central `tags` module via +`context.tf`. + +## What this module enforces + +|Control|How it is enforced| +|-|-| +|Creation gate|All `for_each` resource maps are set to `{}` when `module.this.enabled = false`, preventing any resource creation| +|Context-derived naming and tagging|All resources inherit `module.this.tags`; resolver endpoint and firewall rule group names default to context-derived values| +|Firewall domain list de-duplication|Standalone `aws_route53_resolver_firewall_rule` resources are used for external/aws-managed firewall domain lists to prevent orphan empty domain lists| +|Per-item tag merging|Each resource merges `module.this.tags` with per-item `tags`, ensuring standard tags can never be stripped by a caller| + +## Usage + +### Private hosted zone with records and cross-account authorization + +```hcl +module "r53" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/r53?ref=" + context = module.label.context + + hosted_zones = { + internal = { + name = "screening.internal" + private_zone = true + ignore_vpc = true + + vpc = { + primary = { + vpc_id = module.vpc.vpc_id + vpc_region = "eu-west-2" + } + } + + vpc_association_authorizations = { + shared-services = { + vpc_id = "vpc-0123456789abcdef0" + vpc_region = "eu-west-2" + } + } + + records = { + api = { + type = "A" + alias = { + name = module.alb.dns_name + zone_id = module.alb.zone_id + } + } + } + } + } +} +``` + +### Inbound and outbound resolver endpoints + +```hcl +module "r53" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/r53?ref=" + context = module.label.context + + resolver_endpoints = { + inbound = { + direction = "INBOUND" + type = "IPV4" + protocols = ["Do53"] + vpc_id = module.vpc.vpc_id + + ip_address = [ + { subnet_id = module.vpc.private_subnets[0] }, + { subnet_id = module.vpc.private_subnets[1] }, + ] + + security_group_ingress_rules = { + subnet-a = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[0] + description = "Allow inbound DNS queries from subnet A" + } + subnet-b = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[1] + description = "Allow inbound DNS queries from subnet B" + } + } + + security_group_egress_rules = { + subnet-a = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[0] + description = "Allow DNS responses to subnet A" + } + subnet-b = { + cidr_ipv4 = module.vpc.private_subnets_cidr_blocks[1] + description = "Allow DNS responses to subnet B" + } + } + } + + outbound = { + direction = "OUTBOUND" + type = "IPV4" + protocols = ["Do53", "DoH"] + vpc_id = module.vpc.vpc_id + + ip_address = [ + { subnet_id = module.vpc.private_subnets[0] }, + { subnet_id = module.vpc.private_subnets[1] }, + ] + + rules = { + onprem = { + domain_name = "corp.internal." + rule_type = "FORWARD" + vpc_id = module.vpc.vpc_id + target_ip = [ + { ip = "10.20.0.10" }, + { ip = "10.20.0.11" }, + ] + } + } + } + } +} +``` + +### Resolver DNS Firewall rule group + +```hcl +module "r53" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/r53?ref=" + context = module.label.context + + resolver_firewall_rule_groups = { + default = { + rules = { + block-malware = { + priority = 100 + action = "BLOCK" + block_response = "NODATA" + domains = ["bad.example.", "malware.example."] + } + allow-aws = { + priority = 110 + action = "ALLOW" + domains = ["amazonaws.com.", "amazon.com."] + } + } + } + } +} +``` + +## Conventions + +- Hosted zone names are caller-supplied DNS names; the wrapper does not derive + them from `context.tf`. +- Resolver endpoint and firewall rule group names default to context-derived, + deterministic names based on the item key. +- All resources inherit the standard screening tag set, and per-item `tags` + are merged on top. +- Resolver endpoint resources default to `var.aws_region` when no per-item + `region` is supplied. + +## What this module does NOT do + +- Create `aws_route53_zone_association` resources for VPCs in other accounts. + As with the upstream module, only + `aws_route53_vpc_association_authorization` is handled here. +- Create `aws_route53_resolver_firewall_rule_group_association` resources. + Associate firewall rule groups to VPCs in the consumer stack once the shared + resource exists. +- Abstract every Route53 feature into separate screening opinions. This module + stays deliberately thin and forwards most behavior to the upstream module. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.51.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [hosted\_zones](#module\_hosted\_zones) | terraform-aws-modules/route53/aws | 6.5.0 | +| [resolver\_endpoint\_label](#module\_resolver\_endpoint\_label) | ../tags | n/a | +| [resolver\_endpoints](#module\_resolver\_endpoints) | terraform-aws-modules/route53/aws//modules/resolver-endpoint | 6.5.0 | +| [resolver\_firewall\_association\_label](#module\_resolver\_firewall\_association\_label) | ../tags | n/a | +| [resolver\_firewall\_rule\_group\_label](#module\_resolver\_firewall\_rule\_group\_label) | ../tags | n/a | +| [resolver\_firewall\_rule\_groups](#module\_resolver\_firewall\_rule\_groups) | terraform-aws-modules/route53/aws//modules/resolver-firewall-rule-group | 6.5.0 | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_route53_resolver_firewall_rule.external](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_resolver_firewall_rule) | resource | +| [aws_route53_resolver_firewall_rule_group_association.this](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/route53_resolver_firewall_rule_group_association) | resource | + +## 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 | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | 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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | +| [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 | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [hosted\_zones](#input\_hosted\_zones) | Map of hosted zones to create or adopt.

Each key is a logical identifier used only in Terraform state and outputs.
Each value forwards to the upstream `terraform-aws-modules/route53/aws`
root module.

`name` is the DNS zone name (for example `example.internal` or
`example.nhs.uk`) and is required. |
map(object({
name = string
create = optional(bool, true)
create_zone = optional(bool, true)
private_zone = optional(bool, false)
vpc_id = optional(string)
comment = optional(string)
delegation_set_id = optional(string)
force_destroy = optional(bool)
enable_accelerated_recovery = optional(bool)
ignore_vpc = optional(bool, false)
vpc = optional(map(object({
vpc_id = string
vpc_region = optional(string)
})))
vpc_association_authorizations = optional(map(object({
vpc_id = string
vpc_region = optional(string)
})))
enable_dnssec = optional(bool, false)
create_dnssec_kms_key = optional(bool, true)
dnssec_kms_key_arn = optional(string)
dnssec_kms_key_description = optional(string)
dnssec_kms_key_aliases = optional(list(string), [])
dnssec_kms_key_tags = optional(map(string), {})
dnssec_key_signing_key_name = optional(string)
records = optional(any, {})
tags = optional(map(string), {})
timeouts = optional(object({
create = optional(string)
update = optional(string)
delete = optional(string)
}))
}))
| `{}` | 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 | +| [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 | +| [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 | +| [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 | +| [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 | +| [resolver\_endpoints](#input\_resolver\_endpoints) | Map of Route53 Resolver endpoints to create.

Each key is a logical identifier used in Terraform state and outputs.
Names default to a context-derived value when `name` is omitted. |
map(object({
create = optional(bool, true)
region = optional(string)
name = optional(string)
direction = optional(string, "INBOUND")
type = optional(string)
protocols = optional(list(string), ["Do53"])
ip_address = optional(list(object({
ip = optional(string)
ipv6 = optional(string)
subnet_id = string
})), [])
security_group_ids = optional(list(string), [])
create_security_group = optional(bool, true)
security_group_name = optional(string)
security_group_use_name_prefix = optional(bool, false)
security_group_description = optional(string)
vpc_id = optional(string)
security_group_ingress_rules = optional(map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
})), {})
security_group_egress_rules = optional(map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
})), {})
security_group_tags = optional(map(string), {})
rules = optional(map(object({
domain_name = string
name = optional(string)
rule_type = string
tags = optional(map(string), {})
target_ip = optional(list(object({
ip = string
ipv6 = optional(string)
port = optional(number)
protocol = optional(string)
})))
vpc_id = optional(string)
})), {})
tags = optional(map(string), {})
}))
| `{}` | no | +| [resolver\_firewall\_rule\_groups](#input\_resolver\_firewall\_rule\_groups) | Map of Route53 Resolver DNS Firewall rule groups to create.

Each rule can either create a dedicated firewall domain list via `domains`
or reference an existing one via `firewall_domain_list_id`. |
map(object({
create = optional(bool, true)
region = optional(string)
name = optional(string)
ram_resource_associations = optional(map(object({
resource_share_arn = string
})), {})
vpc_ids = optional(map(string), {})
priority = optional(number, 100)
rules = optional(map(object({
name = optional(string)
domains = optional(list(string))
action = string
block_override_dns_type = optional(string)
block_override_domain = optional(string)
block_override_ttl = optional(number)
block_response = optional(string)
firewall_domain_list_id = optional(string)
firewall_domain_redirection_action = optional(string)
priority = number
q_type = optional(string)
tags = optional(map(string), {})
})), {})
tags = optional(map(string), {})
}))
| `{}` | 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 | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [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 | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [hosted\_zone\_arns](#output\_hosted\_zone\_arns) | Map of hosted zone key -> Route53 hosted zone ARN. | +| [hosted\_zone\_ids](#output\_hosted\_zone\_ids) | Map of hosted zone key -> Route53 hosted zone ID. | +| [hosted\_zone\_name\_servers](#output\_hosted\_zone\_name\_servers) | Map of hosted zone key -> authoritative name servers. | +| [hosted\_zone\_names](#output\_hosted\_zone\_names) | Map of hosted zone key -> Route53 hosted zone name. | +| [hosted\_zone\_records](#output\_hosted\_zone\_records) | Map of hosted zone key -> records created in the zone. | +| [resolver\_endpoint\_arns](#output\_resolver\_endpoint\_arns) | Map of resolver endpoint key -> endpoint ARN. | +| [resolver\_endpoint\_created\_security\_group\_ids](#output\_resolver\_endpoint\_created\_security\_group\_ids) | Map of resolver endpoint key -> created security group ID. | +| [resolver\_endpoint\_host\_vpc\_ids](#output\_resolver\_endpoint\_host\_vpc\_ids) | Map of resolver endpoint key -> host VPC ID. | +| [resolver\_endpoint\_ids](#output\_resolver\_endpoint\_ids) | Map of resolver endpoint key -> endpoint ID. | +| [resolver\_endpoint\_ip\_addresses](#output\_resolver\_endpoint\_ip\_addresses) | Map of resolver endpoint key -> endpoint IP addresses. | +| [resolver\_endpoint\_rules](#output\_resolver\_endpoint\_rules) | Map of resolver endpoint key -> resolver rules created by that endpoint module. | +| [resolver\_endpoint\_security\_group\_arns](#output\_resolver\_endpoint\_security\_group\_arns) | Map of resolver endpoint key -> created security group ARN. | +| [resolver\_endpoint\_security\_group\_ids](#output\_resolver\_endpoint\_security\_group\_ids) | Map of resolver endpoint key -> attached security group IDs. | +| [resolver\_firewall\_rule\_group\_arns](#output\_resolver\_firewall\_rule\_group\_arns) | Map of firewall rule group key -> rule group ARN. | +| [resolver\_firewall\_rule\_group\_domain\_lists](#output\_resolver\_firewall\_rule\_group\_domain\_lists) | Map of firewall rule group key -> domain lists created in that group. | +| [resolver\_firewall\_rule\_group\_ids](#output\_resolver\_firewall\_rule\_group\_ids) | Map of firewall rule group key -> rule group ID. | +| [resolver\_firewall\_rule\_group\_ram\_resource\_associations](#output\_resolver\_firewall\_rule\_group\_ram\_resource\_associations) | Map of firewall rule group key -> RAM resource associations created for that group. | +| [resolver\_firewall\_rule\_group\_rules](#output\_resolver\_firewall\_rule\_group\_rules) | Map of firewall rule group key -> firewall rules created in that group. | +| [resolver\_firewall\_rule\_group\_share\_statuses](#output\_resolver\_firewall\_rule\_group\_share\_statuses) | Map of firewall rule group key -> RAM share status. | + + + diff --git a/infrastructure/modules/r53/context.tf b/infrastructure/modules/r53/context.tf new file mode 100644 index 00000000..9a4652b0 --- /dev/null +++ b/infrastructure/modules/r53/context.tf @@ -0,0 +1,377 @@ +# 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 = {} + # 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. + 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 +} + +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 diff --git a/infrastructure/modules/r53/locals.tf b/infrastructure/modules/r53/locals.tf new file mode 100644 index 00000000..c6da6585 --- /dev/null +++ b/infrastructure/modules/r53/locals.tf @@ -0,0 +1,42 @@ +################################################################ +# Locals — Firewall Rule Group helpers +# +# These locals support the upstream defect workaround documented +# in main.tf. See the REVERT INSTRUCTIONS comment there for +# removal guidance once the upstream fix lands. +################################################################ + +locals { + # Rules that create their own domain list (passed to community module) + firewall_domain_rules = { + for gk, g in var.resolver_firewall_rule_groups : gk => { + for rk, r in g.rules : rk => r + if r.firewall_domain_list_id == null + } + } + + # Rules that reference an existing domain list (standalone resources) + firewall_external_rules = merge([ + for gk, g in var.resolver_firewall_rule_groups : { + for rk, r in g.rules : "${gk}/${rk}" => merge(r, { group_key = gk }) + if r.firewall_domain_list_id != null + } + ]...) +} + +################################################################ +# Locals — Firewall Rule Group VPC Associations +################################################################ + +locals { + firewall_vpc_associations = merge([ + for group_key, group in var.resolver_firewall_rule_groups : { + for vpc_key, vpc_id in group.vpc_ids : + "${group_key}-${vpc_key}" => { + group_key = group_key + vpc_id = vpc_id + priority = group.priority + } + } + ]...) +} diff --git a/infrastructure/modules/r53/main.tf b/infrastructure/modules/r53/main.tf new file mode 100644 index 00000000..dea2dc5c --- /dev/null +++ b/infrastructure/modules/r53/main.tf @@ -0,0 +1,200 @@ +################################################################ +# Route53 Module +# +# Screening wrapper around the community +# `terraform-aws-modules/route53/aws` modules: +# - root module -> hosted zones, records, DNSSEC +# - modules/resolver-endpoint -> Route53 Resolver endpoints and rules +# - modules/resolver-firewall-rule-group -> Resolver DNS Firewall rule groups +# +# Naming and tagging are derived from context.tf via module.this. +################################################################ + +module "resolver_endpoint_label" { + source = "../tags" + for_each = var.resolver_endpoints + + context = module.this.context + attributes = concat(module.this.attributes, ["resolver-endpoint", each.key]) +} + +module "resolver_firewall_rule_group_label" { + source = "../tags" + for_each = var.resolver_firewall_rule_groups + + context = module.this.context + attributes = concat(module.this.attributes, ["resolver-firewall", each.key]) +} + +module "resolver_firewall_association_label" { + source = "../tags" + for_each = module.this.enabled ? local.firewall_vpc_associations : {} + + context = module.this.context + attributes = concat(module.this.attributes, ["resolver-fw-assoc", each.key]) + id_length_limit = 64 +} + +module "hosted_zones" { + source = "terraform-aws-modules/route53/aws" + version = "6.5.0" + + for_each = module.this.enabled ? var.hosted_zones : {} + + comment = each.value.comment + create = each.value.create + create_dnssec_kms_key = each.value.create_dnssec_kms_key + create_zone = each.value.create_zone + delegation_set_id = each.value.delegation_set_id + dnssec_key_signing_key_name = each.value.dnssec_key_signing_key_name + dnssec_kms_key_aliases = each.value.dnssec_kms_key_aliases + dnssec_kms_key_arn = each.value.dnssec_kms_key_arn + dnssec_kms_key_description = each.value.dnssec_kms_key_description + dnssec_kms_key_tags = merge(module.this.tags, each.value.dnssec_kms_key_tags) + enable_accelerated_recovery = each.value.enable_accelerated_recovery + enable_dnssec = each.value.enable_dnssec + force_destroy = each.value.force_destroy + ignore_vpc = each.value.ignore_vpc + name = each.value.name + private_zone = each.value.private_zone + records = each.value.records + tags = merge(module.this.tags, each.value.tags) + timeouts = each.value.timeouts + vpc = each.value.vpc + vpc_association_authorizations = each.value.vpc_association_authorizations + vpc_id = each.value.vpc_id +} + +module "resolver_endpoints" { + source = "terraform-aws-modules/route53/aws//modules/resolver-endpoint" + version = "6.5.0" + + for_each = module.this.enabled ? var.resolver_endpoints : {} + + create = each.value.create + direction = each.value.direction + ip_address = each.value.ip_address + name = coalesce(each.value.name, module.resolver_endpoint_label[each.key].id) + protocols = length(each.value.protocols) > 0 ? each.value.protocols : ["Do53"] + region = coalesce(each.value.region, var.aws_region) + rules = each.value.rules + tags = merge(module.resolver_endpoint_label[each.key].tags, each.value.tags) + type = each.value.type + + create_security_group = each.value.create_security_group + security_group_description = each.value.security_group_description + security_group_egress_rules = each.value.security_group_egress_rules + security_group_ids = each.value.security_group_ids + security_group_ingress_rules = each.value.security_group_ingress_rules + security_group_name = coalesce(each.value.security_group_name, module.resolver_endpoint_label[each.key].id) + security_group_tags = merge(module.resolver_endpoint_label[each.key].tags, each.value.security_group_tags) + security_group_use_name_prefix = each.value.security_group_use_name_prefix + vpc_id = each.value.vpc_id +} + +################################################################ +# Firewall Rule Groups +# +# UPSTREAM DEFECT (terraform-aws-modules/route53/aws v6.5.0) +# ────────────────────────────────────────────────────────── +# The community resolver-firewall-rule-group submodule creates +# an `aws_route53_resolver_firewall_domain_list` for EVERY rule +# in the `rules` map, regardless of whether a rule already +# supplies its own `firewall_domain_list_id` (e.g. an AWS- +# managed threat list or a RAM-shared domain list). +# +# In the rule resource it uses: +# firewall_domain_list_id = try( +# coalesce(each.value.firewall_domain_list_id, +# aws_route53_resolver_firewall_domain_list.this[each.key].id), +# null) +# +# So the provided ID takes precedence, but the duplicate +# customer-owned domain list is still created as an empty +# orphan. This wastes resources and causes confusion in the +# console (e.g. 4 empty customer-owned lists alongside 4 AWS- +# managed lists for the same threat categories). +# +# WORKAROUND +# ────────── +# We split each rule group's rules into two sets: +# +# 1. "domain" rules – rules where `domains` is populated and +# `firewall_domain_list_id` is null. These are passed to +# the community module which correctly creates a domain +# list and wires it to the rule. +# +# 2. "external" rules – rules where `firewall_domain_list_id` +# is set (AWS-managed lists, RAM-shared lists, or any +# pre-existing list). These bypass the community module +# entirely and are created as standalone +# `aws_route53_resolver_firewall_rule` resources attached +# to the same rule group. +# +# REVERT INSTRUCTIONS (when upstream is fixed) +# ───────────────────────────────────────────── +# Once the community module conditionally creates domain lists +# only for rules that do NOT supply `firewall_domain_list_id`: +# +# 1. Remove the `firewall_domain_rules` and +# `firewall_external_rules` locals in `locals.tf`. +# 2. Remove the `aws_route53_resolver_firewall_rule.external` +# resource block. +# 3. Change `module.resolver_firewall_rule_groups` to pass +# `rules = each.value.rules` instead of +# `rules = local.firewall_domain_rules[each.key]`. +# 4. Run `terraform plan` to confirm the external rules are +# adopted by the community module with no diff. +################################################################ + +module "resolver_firewall_rule_groups" { + source = "terraform-aws-modules/route53/aws//modules/resolver-firewall-rule-group" + version = "6.5.0" + + for_each = module.this.enabled ? var.resolver_firewall_rule_groups : {} + + create = each.value.create + name = coalesce(each.value.name, module.resolver_firewall_rule_group_label[each.key].id) + ram_resource_associations = each.value.ram_resource_associations + region = coalesce(each.value.region, var.aws_region) + rules = local.firewall_domain_rules[each.key] + tags = merge(module.resolver_firewall_rule_group_label[each.key].tags, each.value.tags) +} + +# Standalone rules for existing/AWS-managed domain lists. +# These bypass the community module to avoid orphan domain list +# creation (see UPSTREAM DEFECT comment above). +# REVERT: Remove this resource block once the upstream fix lands. +resource "aws_route53_resolver_firewall_rule" "external" { + for_each = module.this.enabled ? local.firewall_external_rules : {} + + action = each.value.action + block_override_dns_type = each.value.block_override_dns_type + block_override_domain = each.value.block_override_domain + block_override_ttl = each.value.block_override_ttl + block_response = each.value.block_response + firewall_domain_list_id = each.value.firewall_domain_list_id + firewall_domain_redirection_action = each.value.firewall_domain_redirection_action + firewall_rule_group_id = module.resolver_firewall_rule_groups[each.value.group_key].id + name = coalesce(each.value.name, element(split("/", each.key), 1)) + priority = each.value.priority + q_type = each.value.q_type +} + +################################################################ +# Firewall Rule Group VPC Associations +# +# The community module does not create VPC associations, so we +# create them here. +################################################################ + +resource "aws_route53_resolver_firewall_rule_group_association" "this" { + for_each = module.this.enabled ? local.firewall_vpc_associations : {} + + name = module.resolver_firewall_association_label[each.key].id + firewall_rule_group_id = module.resolver_firewall_rule_groups[each.value.group_key].id + vpc_id = each.value.vpc_id + priority = each.value.priority + + tags = module.resolver_firewall_association_label[each.key].tags +} diff --git a/infrastructure/modules/r53/outputs.tf b/infrastructure/modules/r53/outputs.tf new file mode 100644 index 00000000..ea73b874 --- /dev/null +++ b/infrastructure/modules/r53/outputs.tf @@ -0,0 +1,94 @@ +output "hosted_zone_ids" { + description = "Map of hosted zone key -> Route53 hosted zone ID." + value = { for key, zone in module.hosted_zones : key => zone.id } +} + +output "hosted_zone_arns" { + description = "Map of hosted zone key -> Route53 hosted zone ARN." + value = { for key, zone in module.hosted_zones : key => zone.arn } +} + +output "hosted_zone_names" { + description = "Map of hosted zone key -> Route53 hosted zone name." + value = { for key, zone in module.hosted_zones : key => zone.name } +} + +output "hosted_zone_name_servers" { + description = "Map of hosted zone key -> authoritative name servers." + value = { for key, zone in module.hosted_zones : key => zone.name_servers } +} + +output "hosted_zone_records" { + description = "Map of hosted zone key -> records created in the zone." + value = { for key, zone in module.hosted_zones : key => zone.records } +} + +output "resolver_endpoint_ids" { + description = "Map of resolver endpoint key -> endpoint ID." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.id } +} + +output "resolver_endpoint_arns" { + description = "Map of resolver endpoint key -> endpoint ARN." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.arn } +} + +output "resolver_endpoint_host_vpc_ids" { + description = "Map of resolver endpoint key -> host VPC ID." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.host_vpc_id } +} + +output "resolver_endpoint_security_group_ids" { + description = "Map of resolver endpoint key -> attached security group IDs." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.security_group_ids } +} + +output "resolver_endpoint_security_group_arns" { + description = "Map of resolver endpoint key -> created security group ARN." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.security_group_arn } +} + +output "resolver_endpoint_created_security_group_ids" { + description = "Map of resolver endpoint key -> created security group ID." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.security_group_id } +} + +output "resolver_endpoint_ip_addresses" { + description = "Map of resolver endpoint key -> endpoint IP addresses." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.ip_addresses } +} + +output "resolver_endpoint_rules" { + description = "Map of resolver endpoint key -> resolver rules created by that endpoint module." + value = { for key, endpoint in module.resolver_endpoints : key => endpoint.rules } +} + +output "resolver_firewall_rule_group_ids" { + description = "Map of firewall rule group key -> rule group ID." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.id } +} + +output "resolver_firewall_rule_group_arns" { + description = "Map of firewall rule group key -> rule group ARN." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.arn } +} + +output "resolver_firewall_rule_group_share_statuses" { + description = "Map of firewall rule group key -> RAM share status." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.share_status } +} + +output "resolver_firewall_rule_group_domain_lists" { + description = "Map of firewall rule group key -> domain lists created in that group." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.domain_lists } +} + +output "resolver_firewall_rule_group_rules" { + description = "Map of firewall rule group key -> firewall rules created in that group." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.rules } +} + +output "resolver_firewall_rule_group_ram_resource_associations" { + description = "Map of firewall rule group key -> RAM resource associations created for that group." + value = { for key, group in module.resolver_firewall_rule_groups : key => group.ram_resource_associations } +} diff --git a/infrastructure/modules/r53/variables.tf b/infrastructure/modules/r53/variables.tf new file mode 100644 index 00000000..a8c43749 --- /dev/null +++ b/infrastructure/modules/r53/variables.tf @@ -0,0 +1,164 @@ +################################################################ +# Route53-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# `context.tf` via `module.this`. +################################################################ + +variable "hosted_zones" { + description = <<-EOT + Map of hosted zones to create or adopt. + + Each key is a logical identifier used only in Terraform state and outputs. + Each value forwards to the upstream `terraform-aws-modules/route53/aws` + root module. + + `name` is the DNS zone name (for example `example.internal` or + `example.nhs.uk`) and is required. + EOT + + type = map(object({ + name = string + create = optional(bool, true) + create_zone = optional(bool, true) + private_zone = optional(bool, false) + vpc_id = optional(string) + comment = optional(string) + delegation_set_id = optional(string) + force_destroy = optional(bool) + enable_accelerated_recovery = optional(bool) + ignore_vpc = optional(bool, false) + vpc = optional(map(object({ + vpc_id = string + vpc_region = optional(string) + }))) + vpc_association_authorizations = optional(map(object({ + vpc_id = string + vpc_region = optional(string) + }))) + enable_dnssec = optional(bool, false) + create_dnssec_kms_key = optional(bool, true) + dnssec_kms_key_arn = optional(string) + dnssec_kms_key_description = optional(string) + dnssec_kms_key_aliases = optional(list(string), []) + dnssec_kms_key_tags = optional(map(string), {}) + dnssec_key_signing_key_name = optional(string) + records = optional(any, {}) + tags = optional(map(string), {}) + timeouts = optional(object({ + create = optional(string) + update = optional(string) + delete = optional(string) + })) + })) + default = {} +} + +variable "resolver_endpoints" { + description = <<-EOT + Map of Route53 Resolver endpoints to create. + + Each key is a logical identifier used in Terraform state and outputs. + Names default to a context-derived value when `name` is omitted. + EOT + + type = map(object({ + create = optional(bool, true) + region = optional(string) + name = optional(string) + direction = optional(string, "INBOUND") + type = optional(string) + protocols = optional(list(string), ["Do53"]) + ip_address = optional(list(object({ + ip = optional(string) + ipv6 = optional(string) + subnet_id = string + })), []) + security_group_ids = optional(list(string), []) + create_security_group = optional(bool, true) + security_group_name = optional(string) + security_group_use_name_prefix = optional(bool, false) + security_group_description = optional(string) + vpc_id = optional(string) + security_group_ingress_rules = optional(map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + })), {}) + security_group_egress_rules = optional(map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + })), {}) + security_group_tags = optional(map(string), {}) + rules = optional(map(object({ + domain_name = string + name = optional(string) + rule_type = string + tags = optional(map(string), {}) + target_ip = optional(list(object({ + ip = string + ipv6 = optional(string) + port = optional(number) + protocol = optional(string) + }))) + vpc_id = optional(string) + })), {}) + tags = optional(map(string), {}) + })) + default = {} + + validation { + condition = alltrue([ + for key, ep in var.resolver_endpoints : + alltrue([ + for p in ep.protocols : contains(["Do53", "DoH", "DoH-FIPS"], p) + ]) + ]) + error_message = "Each protocol value must be one of: Do53, DoH, DoH-FIPS. An empty list defaults to Do53." + } +} + +variable "resolver_firewall_rule_groups" { + description = <<-EOT + Map of Route53 Resolver DNS Firewall rule groups to create. + + Each rule can either create a dedicated firewall domain list via `domains` + or reference an existing one via `firewall_domain_list_id`. + EOT + + type = map(object({ + create = optional(bool, true) + region = optional(string) + name = optional(string) + ram_resource_associations = optional(map(object({ + resource_share_arn = string + })), {}) + vpc_ids = optional(map(string), {}) + priority = optional(number, 100) + rules = optional(map(object({ + name = optional(string) + domains = optional(list(string)) + action = string + block_override_dns_type = optional(string) + block_override_domain = optional(string) + block_override_ttl = optional(number) + block_response = optional(string) + firewall_domain_list_id = optional(string) + firewall_domain_redirection_action = optional(string) + priority = number + q_type = optional(string) + tags = optional(map(string), {}) + })), {}) + tags = optional(map(string), {}) + })) + default = {} +} diff --git a/infrastructure/modules/r53/versions.tf b/infrastructure/modules/r53/versions.tf new file mode 100644 index 00000000..cb30fe5c --- /dev/null +++ b/infrastructure/modules/r53/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" + } + } +} 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 7b4042236071c675de2e75710a93d1d21deeab79 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Tue, 23 Jun 2026 17:26:51 +0100 Subject: [PATCH 036/118] Added doc format fixes --- infrastructure/modules/rds/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 437988f3..0c3aed31 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -2,12 +2,12 @@ Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). -The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring iam role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. ## What this module enforces |Control|Value|Reason| -|-|-|-| +|---|---|---| |`publicly_accessible`|`false`|Databases must never be internet-facing| |`storage_encrypted`|`true`|Encryption at rest is mandatory| |`copy_tags_to_snapshot`|`true`|Snapshots must carry the same tags as the instance| @@ -256,7 +256,7 @@ The master password arn is exposed via the `master_user_secret_arn` output. - Resource creation is gated by `module.this.enabled`. - Snapshot tagging is always enabled via `copy_tags_to_snapshot = true`. - Resource arn values (e.g., `instance_arn`) are exposed as output attributes. -- iam authentication and resource tagging require the instance resource ID. +- IAM authentication and resource tagging require the instance resource ID. ## What this module does NOT do From 4122b0b63a1753ad42b6bc921e31ccff57879460 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Tue, 23 Jun 2026 18:15:59 +0100 Subject: [PATCH 037/118] Made more improvements --- infrastructure/modules/alb/README.md | 18 +++++++++++++++++- infrastructure/modules/alb/versions.tf | 4 ++-- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md index 54bdbec7..9533afab 100644 --- a/infrastructure/modules/alb/README.md +++ b/infrastructure/modules/alb/README.md @@ -2,7 +2,7 @@ Thin NHS wrapper around [terraform-aws-modules/alb/aws](https://registry.terraform.io/modules/terraform-aws-modules/alb/aws) that enforces the screening platform's baseline controls. -## Fixed controls +## What this module enforces | Setting | Value | Reason | |---|---|---| @@ -171,6 +171,22 @@ module "nlb" { } ``` +## Conventions + +* The load balancer name is derived from `module.this.id` and cannot be overridden — pass `context`, `name`, and `label_order` to control the generated name. +* `internal` defaults to `false` (internet-facing). Set `internal = true` for ALBs and NLBs that should only be reachable from within the VPC. +* Security group rules are caller-supplied via `security_group_ingress_rules` and `security_group_egress_rules`. This supports both ALB (HTTP/HTTPS) and NLB (TCP/TLS) patterns without constraining port numbers. +* Access logging is optional. Supply an S3 bucket ARN via `access_logs` to enable it. NHS production environments should always enable access logging. +* For NLBs, `drop_invalid_header_fields` is set to `null` automatically — this argument is only valid for ALBs. + +## What this module does NOT do + +* Create SSL/TLS certificates — use the `acm` module and pass the certificate ARN into `listeners`. +* Create an S3 bucket for access logs — use the `s3-bucket` module and pass the bucket ID via `access_logs.bucket`. +* Create Route53 DNS records — create an alias record pointing to `module.alb.dns_name` and `module.alb.zone_id` in your stack. +* Create a WAF Web ACL — use the `waf` module and pass the ARN via `web_acl_arn`. +* Enforce a minimum TLS policy — callers must specify `ssl_policy` on HTTPS listeners; use `ELBSecurityPolicy-TLS13-1-2-Res-2021-06` or later. + ## Outputs | Name | Description | Used by | diff --git a/infrastructure/modules/alb/versions.tf b/infrastructure/modules/alb/versions.tf index d2afd5f9..cb30fe5c 100644 --- a/infrastructure/modules/alb/versions.tf +++ b/infrastructure/modules/alb/versions.tf @@ -1,10 +1,10 @@ terraform { - required_version = ">= 1.5.7" + required_version = ">= 1.13" required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.28" + version = ">= 6.42" } } } From 8a476b784886c7ef10976af008175f501ab0a7da Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Fri, 12 Jun 2026 14:10:07 +0100 Subject: [PATCH 038/118] Created inital RDS module --- infrastructure/modules/rds/README.md | 151 ++++++++++ infrastructure/modules/rds/context.tf | 374 ++++++++++++++++++++++++ infrastructure/modules/rds/main.tf | 148 ++++++++++ infrastructure/modules/rds/outputs.tf | 82 ++++++ infrastructure/modules/rds/variables.tf | 282 ++++++++++++++++++ infrastructure/modules/rds/versions.tf | 10 + 6 files changed, 1047 insertions(+) create mode 100644 infrastructure/modules/rds/README.md create mode 100644 infrastructure/modules/rds/context.tf create mode 100644 infrastructure/modules/rds/main.tf create mode 100644 infrastructure/modules/rds/outputs.tf create mode 100644 infrastructure/modules/rds/variables.tf create mode 100644 infrastructure/modules/rds/versions.tf diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md new file mode 100644 index 00000000..73ca0294 --- /dev/null +++ b/infrastructure/modules/rds/README.md @@ -0,0 +1,151 @@ +# RDS Module + +Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). + +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. It also creates a security group unless the caller supplies their own via `vpc_security_group_ids`. + +## Fixed controls + +These values are always enforced and cannot be overridden by callers. + +| Control | Value | Reason | +|---------|-------|--------| +| `publicly_accessible` | `false` | Databases must never be internet-facing | +| `storage_encrypted` | `true` | Encryption at rest is mandatory | +| `copy_tags_to_snapshot` | `true` | Snapshots must carry the same tags as the instance | +| `auto_minor_version_upgrade` | `false` | Teams keep instances in sync with the production engine version | +| `create_db_subnet_group` | `true` | Subnet group is always managed by this module | + +## Usage + +### Oracle with a fresh database + +```hcl +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + # context + service = var.service + environment = var.environment + workspace = terraform.workspace + + # identity + identifier = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" + + # engine + engine = "oracle-ee" + engine_version = "19" + license_model = "license-included" + major_engine_version = "19" + family = "oracle-ee-19" + character_set_name = "AL32UTF8" + + # sizing + instance_class = "db.m5.large" + allocated_storage = 100 + storage_type = "gp3" + + # database + db_name = "MYDB" + username = var.rds_master_username + port = 1521 + + # credentials (write-only — not stored in state) + password_wo = var.rds_master_password + password_wo_version = 1 + + # networking + subnet_ids = data.aws_subnets.private.ids + vpc_id = data.aws_vpc.selected.id + pi_port = 1529 + pi_cidr_block = ["10.0.0.0/8"] + + # options (Oracle S3 integration and timezone) + options = [ + { + option_name = "S3_INTEGRATION" + version = "1.0" + port = 0 + vpc_security_group_memberships = [] + option_settings = [] + }, + { + option_name = "Timezone" + port = 0 + vpc_security_group_memberships = [] + option_settings = [ + { name = "TIME_ZONE", value = "Europe/London" } + ] + } + ] + + # parameters + parameters = [ + { name = "_add_col_optim_enabled", value = "TRUE", apply_method = "immediate" } + ] + + # availability and backup + multi_az = var.environment == "prod" ? true : false + backup_retention_period = 7 + skip_final_snapshot = false + deletion_protection = var.environment == "prod" ? true : false +} +``` + +### Restore from snapshot + +```hcl +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + # ... (same as above) + + # When restoring from a snapshot, character_set_name must be null + character_set_name = null + snapshot_identifier = "rds:my-db-2024-01-01-06-00" +} +``` + +### RDS-managed password (no password in Terraform state) + +```hcl +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + # ... + + manage_master_user_password = true + # password_wo is not required when manage_master_user_password = true +} +``` + +The master password ARN is exposed via the `master_user_secret_arn` output. + +## Outputs + +| Name | Description | +|------|-------------| +| `instance_address` | Hostname of the RDS instance (without port) | +| `instance_port` | Port number | +| `instance_endpoint` | Connection endpoint in `host:port` format | +| `instance_id` | RDS instance identifier | +| `instance_arn` | ARN of the RDS instance | +| `instance_resource_id` | RDS resource ID (used for IAM authentication) | +| `master_user_secret_arn` | Secrets Manager ARN for the master password (when `manage_master_user_password = true`) | +| `rds_security_group` | Full security group object (`.id`, `.arn`, etc.) — null when `vpc_security_group_ids` is provided | +| `security_group_id` | Security group ID — null when `vpc_security_group_ids` is provided | +| `rds_subnet_group` | Object with `.id` and `.arn` for the DB subnet group | +| `db_subnet_group_id` | DB subnet group name/ID | +| `db_parameter_group_id` | DB parameter group ID | +| `db_option_group_id` | DB option group ID | +| `enhanced_monitoring_iam_role_arn` | Enhanced Monitoring IAM role ARN (empty when `monitoring_interval = 0`) | + +## Migration from the local bcss `rds` module + +When migrating from `../../modules/rds` in the bcss repo to this shared module, note: + +1. **Output names** — `instance_endpoint`, `instance_address`, `instance_port`, and `rds_security_group` are compatible. `rds_subnet_group` is compatible (both expose an object with `.id`). +2. **`snapshot_identifier`** — The local module used `""` as "no snapshot". This module uses `null`. Update the calling stack. +3. **`password_wo`** — The local module accepted `master_password` as a plain variable (stored in state). This module uses `password_wo` (write-only, not persisted in state). +4. **`deletion_protection`** — Defaults to `true` here (defaults to whatever `var.deletion_protection` was in the local module). Add `#checkov:skip=CKV_AWS_293` to the module call in non-production stacks. +5. **`ignore_changes` lifecycle** — The local module ignored `engine`, `engine_version`, `availability_zone`, `db_subnet_group_name`, and `storage_encrypted` on the `aws_db_instance`. These lifecycle rules are inside the community module and cannot be overridden from a wrapper. Raise this as a known limitation in the migration PR. diff --git a/infrastructure/modules/rds/context.tf b/infrastructure/modules/rds/context.tf new file mode 100644 index 00000000..39d9b945 --- /dev/null +++ b/infrastructure/modules/rds/context.tf @@ -0,0 +1,374 @@ +# +# 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" { + source = "../tags" + + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf new file mode 100644 index 00000000..d4af04c9 --- /dev/null +++ b/infrastructure/modules/rds/main.tf @@ -0,0 +1,148 @@ +data "aws_vpc" "selected" { + id = var.vpc_id +} + +locals { + create_security_group = length(var.vpc_security_group_ids) == 0 + effective_ingress_cidr_blocks = length(var.ingress_cidr_blocks) > 0 ? var.ingress_cidr_blocks : [data.aws_vpc.selected.cidr_block] +} + +# ---------------------------------------------------------------------------- +# Security group for the RDS instance. +# +# Only created when vpc_security_group_ids is not provided. The security group +# allows inbound traffic on the DB port (and optionally the Performance Insights +# agent port) from the VPC CIDR or caller-supplied CIDR blocks, and restricts +# outbound traffic to HTTPS only. +# ---------------------------------------------------------------------------- + +# tflint-ignore: terraform_required_providers +resource "aws_security_group" "this" { + # checkov:skip=CKV2_AWS_5: SG is attached to the RDS instance via vpc_security_group_ids in the community module below + count = local.create_security_group ? 1 : 0 + + name_prefix = "${module.this.id}-rds-" + description = "Allow VPC traffic to ${var.engine} RDS instance on port ${var.port}" + vpc_id = var.vpc_id + + ingress { + description = "DB port from VPC" + from_port = var.port + to_port = var.port + protocol = "tcp" + cidr_blocks = local.effective_ingress_cidr_blocks + } + + dynamic "ingress" { + for_each = var.pi_port != null ? [1] : [] + content { + description = "Performance Insights agent port" + from_port = var.pi_port + to_port = var.pi_port + protocol = "tcp" + cidr_blocks = length(var.pi_cidr_block) > 0 ? var.pi_cidr_block : local.effective_ingress_cidr_blocks + } + } + + egress { + description = "HTTPS egress for AWS service communication" + from_port = 443 + to_port = 443 + protocol = "tcp" + cidr_blocks = ["0.0.0.0/0"] + ipv6_cidr_blocks = ["::/0"] + } + + tags = merge(module.this.tags, { Name = "${module.this.id}-rds" }) + + lifecycle { + create_before_destroy = true + } +} + +# ---------------------------------------------------------------------------- +# RDS instance +# +# Wraps terraform-aws-modules/rds/aws v7.2.0. +# +# Fixed controls (not exposed as variables): +# - publicly_accessible = false (databases must never be internet-facing) +# - storage_encrypted = true (encryption at rest is mandatory) +# - copy_tags_to_snapshot = true (snapshots must carry the same tags) +# - auto_minor_version_upgrade = false (teams keep instances in sync with prod) +# - create_db_subnet_group = true (subnet group always managed by this module) +# ---------------------------------------------------------------------------- +module "rds" { + source = "terraform-aws-modules/rds/aws" + version = "7.2.0" + + identifier = var.identifier + + # Engine + engine = var.engine + engine_version = var.engine_version + license_model = var.license_model + character_set_name = var.character_set_name + + # Instance sizing + instance_class = var.instance_class + allocated_storage = var.allocated_storage + max_allocated_storage = var.max_allocated_storage + storage_type = var.storage_type + iops = var.iops + storage_encrypted = true + kms_key_id = var.kms_key_id + + # Database + db_name = var.db_name + username = var.username + port = var.port + + # Credentials + manage_master_user_password = var.manage_master_user_password + password_wo = var.password_wo + password_wo_version = var.password_wo_version + + # Networking + publicly_accessible = false + vpc_security_group_ids = local.create_security_group ? [aws_security_group.this[0].id] : var.vpc_security_group_ids + + # Subnet group (always managed by this module) + create_db_subnet_group = true + subnet_ids = var.subnet_ids + + # Parameter group + family = var.family + parameters = var.parameters + + # Option group + major_engine_version = var.major_engine_version + options = var.options + + # Monitoring + monitoring_interval = var.monitoring_interval + create_monitoring_role = var.monitoring_interval > 0 + + # Availability and backup + multi_az = var.multi_az + backup_retention_period = var.backup_retention_period + backup_window = var.backup_window + maintenance_window = var.maintenance_window + skip_final_snapshot = var.skip_final_snapshot + snapshot_identifier = var.snapshot_identifier + apply_immediately = var.apply_immediately + copy_tags_to_snapshot = true + auto_minor_version_upgrade = false + + # Performance Insights + performance_insights_enabled = var.performance_insights_enabled + performance_insights_retention_period = var.performance_insights_enabled ? var.performance_insights_retention_period : null + performance_insights_kms_key_id = var.performance_insights_kms_key_id + + # Lifecycle + deletion_protection = var.deletion_protection + + timeouts = var.timeouts + + tags = module.this.tags +} diff --git a/infrastructure/modules/rds/outputs.tf b/infrastructure/modules/rds/outputs.tf new file mode 100644 index 00000000..60bf8117 --- /dev/null +++ b/infrastructure/modules/rds/outputs.tf @@ -0,0 +1,82 @@ +# Instance connection details + +output "instance_address" { + description = "Hostname of the RDS instance (without port)" + value = module.rds.db_instance_address +} + +output "instance_port" { + description = "Port on which the RDS instance accepts connections" + value = module.rds.db_instance_port +} + +output "instance_endpoint" { + description = "Connection endpoint for the RDS instance in host:port format" + value = module.rds.db_instance_endpoint +} + +output "instance_id" { + description = "Identifier of the RDS instance" + value = module.rds.db_instance_identifier +} + +output "instance_arn" { + description = "ARN of the RDS instance" + value = module.rds.db_instance_arn +} + +output "instance_resource_id" { + description = "The RDS resource ID (used for IAM authentication and tagging)" + value = module.rds.db_instance_resource_id +} + +output "master_user_secret_arn" { + description = "ARN of the Secrets Manager secret for the master user password. Only populated when manage_master_user_password is true" + value = module.rds.db_instance_master_user_secret_arn +} + +# Security group + +output "rds_security_group" { + description = "The security group created for the RDS instance. Null when vpc_security_group_ids was provided by the caller" + value = local.create_security_group ? aws_security_group.this[0] : null +} + +output "security_group_id" { + description = "ID of the RDS security group. Null when vpc_security_group_ids was provided by the caller" + value = local.create_security_group ? aws_security_group.this[0].id : null +} + +# Subnet group + +output "rds_subnet_group" { + description = "The DB subnet group used by the RDS instance, with id and arn attributes" + value = { + id = module.rds.db_subnet_group_id + arn = module.rds.db_subnet_group_arn + } +} + +output "db_subnet_group_id" { + description = "Name/ID of the DB subnet group" + value = module.rds.db_subnet_group_id +} + +# Parameter and option groups + +output "db_parameter_group_id" { + description = "ID of the DB parameter group" + value = module.rds.db_parameter_group_id +} + +output "db_option_group_id" { + description = "ID of the DB option group" + value = module.rds.db_option_group_id +} + +# Monitoring + +output "enhanced_monitoring_iam_role_arn" { + description = "ARN of the Enhanced Monitoring IAM role. Empty when monitoring_interval is 0" + value = module.rds.enhanced_monitoring_iam_role_arn +} diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf new file mode 100644 index 00000000..e5bb8191 --- /dev/null +++ b/infrastructure/modules/rds/variables.tf @@ -0,0 +1,282 @@ +# ---------------------------------------------------------------------------- +# Instance identity +# ---------------------------------------------------------------------------- + +variable "identifier" { + description = "The name of the RDS instance" + type = string +} + +# ---------------------------------------------------------------------------- +# Engine +# ---------------------------------------------------------------------------- + +variable "engine" { + description = "The database engine to use (e.g. 'oracle-ee', 'postgres', 'mysql')" + type = string +} + +variable "engine_version" { + description = "The engine version to use" + type = string +} + +variable "license_model" { + description = "License model for the DB instance. Required for some engines (e.g. Oracle SE1 requires 'license-included')" + type = string + default = null +} + +variable "character_set_name" { + description = "Oracle character set name. Cannot be changed after creation. Must be null when restoring from a snapshot" + type = string + default = null +} + +# ---------------------------------------------------------------------------- +# Instance sizing +# ---------------------------------------------------------------------------- + +variable "instance_class" { + description = "The instance type of the RDS instance (e.g. 'db.m5.large')" + type = string +} + +variable "allocated_storage" { + description = "The allocated storage in gibibytes" + type = number +} + +variable "max_allocated_storage" { + description = "Upper limit for storage autoscaling in gibibytes. Set to 0 to disable autoscaling" + type = number + default = 0 +} + +variable "storage_type" { + description = "One of 'standard', 'gp2', 'gp3', 'io1', or 'io2'. Defaults to 'io1' when iops is set, otherwise 'gp2'" + type = string + default = null +} + +variable "iops" { + description = "Provisioned IOPS. Required when storage_type is 'io1' or 'io2'" + type = number + default = null +} + +variable "kms_key_id" { + description = "ARN of the KMS key for storage encryption. If omitted, the default account KMS key is used" + type = string + default = null +} + +# ---------------------------------------------------------------------------- +# Credentials +# ---------------------------------------------------------------------------- + +variable "username" { + description = "Username for the master DB user" + type = string + sensitive = true +} + +variable "password_wo" { + description = "Write-only password for the master DB user. Required when manage_master_user_password is false and snapshot_identifier is not set" + type = string + default = null + sensitive = true +} + +variable "password_wo_version" { + description = "Increment this value to trigger a password rotation when password_wo changes" + type = number + default = 1 +} + +variable "manage_master_user_password" { + description = "When true, RDS manages the master password in Secrets Manager. When false, password_wo must be provided" + type = bool + default = false +} + +# ---------------------------------------------------------------------------- +# Database +# ---------------------------------------------------------------------------- + +variable "db_name" { + description = "The name of the database to create. Omit to skip initial database creation" + type = string + default = null +} + +variable "port" { + description = "The port on which the DB accepts connections" + type = number +} + +# ---------------------------------------------------------------------------- +# Networking +# ---------------------------------------------------------------------------- + +variable "subnet_ids" { + description = "List of VPC subnet IDs for the DB subnet group" + type = list(string) +} + +variable "vpc_id" { + description = "VPC ID. Used to derive the VPC CIDR for security group ingress when ingress_cidr_blocks is not set" + type = string +} + +variable "vpc_security_group_ids" { + description = "List of existing security group IDs to associate with the instance. When provided, no security group is created by this module" + type = list(string) + default = [] +} + +variable "ingress_cidr_blocks" { + description = "CIDR blocks allowed to connect on the DB port. Defaults to the VPC CIDR when empty" + type = list(string) + default = [] +} + +variable "pi_port" { + description = "Performance Insights agent port. When set, an additional ingress rule is added to the security group" + type = number + default = null +} + +variable "pi_cidr_block" { + description = "CIDR blocks allowed to connect on the Performance Insights port. Defaults to ingress_cidr_blocks when empty" + type = list(string) + default = [] +} + +# ---------------------------------------------------------------------------- +# Parameter group +# ---------------------------------------------------------------------------- + +variable "family" { + description = "DB parameter group family (e.g. 'oracle-ee-19', 'postgres16', 'mysql8.0')" + type = string +} + +variable "parameters" { + description = "List of DB parameters to apply to the parameter group" + type = list(object({ + name = string + value = string + apply_method = optional(string) + })) + default = [] +} + +# ---------------------------------------------------------------------------- +# Option group +# ---------------------------------------------------------------------------- + +variable "major_engine_version" { + description = "Major engine version for the option group (e.g. '19' for Oracle 19c)" + type = string +} + +variable "options" { + description = "List of option group options to apply. See the community module documentation for the full object shape" + type = any + default = [] +} + +# ---------------------------------------------------------------------------- +# Availability and backup +# ---------------------------------------------------------------------------- + +variable "multi_az" { + description = "Specifies if the RDS instance is Multi-AZ" + type = bool + default = false +} + +variable "backup_retention_period" { + description = "Number of days to retain automated backups. Must be between 0 and 35" + type = number + default = 7 +} + +variable "backup_window" { + description = "Daily UTC time range for automated backups (e.g. '23:00-23:30'). Must not overlap with maintenance_window" + type = string + default = "23:00-23:30" +} + +variable "maintenance_window" { + description = "Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00')" + type = string + default = "Sun:00:00-Sun:03:00" +} + +variable "skip_final_snapshot" { + description = "If true, no final snapshot is created on deletion. Should be false in production" + type = bool + default = false +} + +variable "snapshot_identifier" { + description = "Snapshot ID to restore the instance from. When set, character_set_name must be null" + type = string + default = null +} + +variable "apply_immediately" { + description = "Apply modifications immediately rather than deferring to the next maintenance window" + type = bool + default = false +} + +# ---------------------------------------------------------------------------- +# Monitoring and performance +# ---------------------------------------------------------------------------- + +variable "monitoring_interval" { + description = "Interval in seconds between Enhanced Monitoring data points. Valid values: 0, 1, 5, 10, 15, 30, 60. Set to 0 to disable" + type = number + default = 5 +} + +variable "performance_insights_enabled" { + description = "Enable Performance Insights" + type = bool + default = true +} + +variable "performance_insights_retention_period" { + description = "Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31" + type = number + default = 7 +} + +variable "performance_insights_kms_key_id" { + description = "ARN of the KMS key used to encrypt Performance Insights data. If omitted, the default KMS key is used" + type = string + default = null +} + +# ---------------------------------------------------------------------------- +# Lifecycle +# ---------------------------------------------------------------------------- + +variable "deletion_protection" { + description = "Prevents the DB instance from being deleted when true. Should be true in production" + type = bool + default = true +} + +variable "timeouts" { + description = "Terraform resource management timeouts for the DB instance" + type = object({ + create = optional(string) + update = optional(string) + delete = optional(string) + }) + default = null +} diff --git a/infrastructure/modules/rds/versions.tf b/infrastructure/modules/rds/versions.tf new file mode 100644 index 00000000..aaa5e443 --- /dev/null +++ b/infrastructure/modules/rds/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.1" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.28" + } + } +} From 323f3e9d6d47b8964a847c2ddfd00f14b7341783 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 17 Jun 2026 17:04:38 +0100 Subject: [PATCH 039/118] Made PR improvements --- infrastructure/modules/rds/README.md | 12 ++--- infrastructure/modules/rds/main.tf | 64 +------------------------ infrastructure/modules/rds/outputs.tf | 12 ----- infrastructure/modules/rds/variables.tf | 25 +--------- 4 files changed, 6 insertions(+), 107 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 73ca0294..1c010544 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -2,7 +2,7 @@ Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). -The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. It also creates a security group unless the caller supplies their own via `vpc_security_group_ids`. +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. ## Fixed controls @@ -55,10 +55,8 @@ module "oracle_rds" { password_wo_version = 1 # networking - subnet_ids = data.aws_subnets.private.ids - vpc_id = data.aws_vpc.selected.id - pi_port = 1529 - pi_cidr_block = ["10.0.0.0/8"] + subnet_ids = data.aws_subnets.private.ids + vpc_security_group_ids = [module.rds_security_group.security_group_id] # options (Oracle S3 integration and timezone) options = [ @@ -132,8 +130,6 @@ The master password ARN is exposed via the `master_user_secret_arn` output. | `instance_arn` | ARN of the RDS instance | | `instance_resource_id` | RDS resource ID (used for IAM authentication) | | `master_user_secret_arn` | Secrets Manager ARN for the master password (when `manage_master_user_password = true`) | -| `rds_security_group` | Full security group object (`.id`, `.arn`, etc.) — null when `vpc_security_group_ids` is provided | -| `security_group_id` | Security group ID — null when `vpc_security_group_ids` is provided | | `rds_subnet_group` | Object with `.id` and `.arn` for the DB subnet group | | `db_subnet_group_id` | DB subnet group name/ID | | `db_parameter_group_id` | DB parameter group ID | @@ -144,7 +140,7 @@ The master password ARN is exposed via the `master_user_secret_arn` output. When migrating from `../../modules/rds` in the bcss repo to this shared module, note: -1. **Output names** — `instance_endpoint`, `instance_address`, `instance_port`, and `rds_security_group` are compatible. `rds_subnet_group` is compatible (both expose an object with `.id`). +1. **Output names** — `instance_endpoint`, `instance_address`, `instance_port`, and `rds_subnet_group` are compatible with the local module. `rds_security_group` is no longer an output — the caller now owns the security group resource. 2. **`snapshot_identifier`** — The local module used `""` as "no snapshot". This module uses `null`. Update the calling stack. 3. **`password_wo`** — The local module accepted `master_password` as a plain variable (stored in state). This module uses `password_wo` (write-only, not persisted in state). 4. **`deletion_protection`** — Defaults to `true` here (defaults to whatever `var.deletion_protection` was in the local module). Add `#checkov:skip=CKV_AWS_293` to the module call in non-production stacks. diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index d4af04c9..45b45d53 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -1,65 +1,3 @@ -data "aws_vpc" "selected" { - id = var.vpc_id -} - -locals { - create_security_group = length(var.vpc_security_group_ids) == 0 - effective_ingress_cidr_blocks = length(var.ingress_cidr_blocks) > 0 ? var.ingress_cidr_blocks : [data.aws_vpc.selected.cidr_block] -} - -# ---------------------------------------------------------------------------- -# Security group for the RDS instance. -# -# Only created when vpc_security_group_ids is not provided. The security group -# allows inbound traffic on the DB port (and optionally the Performance Insights -# agent port) from the VPC CIDR or caller-supplied CIDR blocks, and restricts -# outbound traffic to HTTPS only. -# ---------------------------------------------------------------------------- - -# tflint-ignore: terraform_required_providers -resource "aws_security_group" "this" { - # checkov:skip=CKV2_AWS_5: SG is attached to the RDS instance via vpc_security_group_ids in the community module below - count = local.create_security_group ? 1 : 0 - - name_prefix = "${module.this.id}-rds-" - description = "Allow VPC traffic to ${var.engine} RDS instance on port ${var.port}" - vpc_id = var.vpc_id - - ingress { - description = "DB port from VPC" - from_port = var.port - to_port = var.port - protocol = "tcp" - cidr_blocks = local.effective_ingress_cidr_blocks - } - - dynamic "ingress" { - for_each = var.pi_port != null ? [1] : [] - content { - description = "Performance Insights agent port" - from_port = var.pi_port - to_port = var.pi_port - protocol = "tcp" - cidr_blocks = length(var.pi_cidr_block) > 0 ? var.pi_cidr_block : local.effective_ingress_cidr_blocks - } - } - - egress { - description = "HTTPS egress for AWS service communication" - from_port = 443 - to_port = 443 - protocol = "tcp" - cidr_blocks = ["0.0.0.0/0"] - ipv6_cidr_blocks = ["::/0"] - } - - tags = merge(module.this.tags, { Name = "${module.this.id}-rds" }) - - lifecycle { - create_before_destroy = true - } -} - # ---------------------------------------------------------------------------- # RDS instance # @@ -105,7 +43,7 @@ module "rds" { # Networking publicly_accessible = false - vpc_security_group_ids = local.create_security_group ? [aws_security_group.this[0].id] : var.vpc_security_group_ids + vpc_security_group_ids = var.vpc_security_group_ids # Subnet group (always managed by this module) create_db_subnet_group = true diff --git a/infrastructure/modules/rds/outputs.tf b/infrastructure/modules/rds/outputs.tf index 60bf8117..7d642c75 100644 --- a/infrastructure/modules/rds/outputs.tf +++ b/infrastructure/modules/rds/outputs.tf @@ -35,18 +35,6 @@ output "master_user_secret_arn" { value = module.rds.db_instance_master_user_secret_arn } -# Security group - -output "rds_security_group" { - description = "The security group created for the RDS instance. Null when vpc_security_group_ids was provided by the caller" - value = local.create_security_group ? aws_security_group.this[0] : null -} - -output "security_group_id" { - description = "ID of the RDS security group. Null when vpc_security_group_ids was provided by the caller" - value = local.create_security_group ? aws_security_group.this[0].id : null -} - # Subnet group output "rds_subnet_group" { diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index e5bb8191..d5fb751f 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -124,31 +124,8 @@ variable "subnet_ids" { type = list(string) } -variable "vpc_id" { - description = "VPC ID. Used to derive the VPC CIDR for security group ingress when ingress_cidr_blocks is not set" - type = string -} - variable "vpc_security_group_ids" { - description = "List of existing security group IDs to associate with the instance. When provided, no security group is created by this module" - type = list(string) - default = [] -} - -variable "ingress_cidr_blocks" { - description = "CIDR blocks allowed to connect on the DB port. Defaults to the VPC CIDR when empty" - type = list(string) - default = [] -} - -variable "pi_port" { - description = "Performance Insights agent port. When set, an additional ingress rule is added to the security group" - type = number - default = null -} - -variable "pi_cidr_block" { - description = "CIDR blocks allowed to connect on the Performance Insights port. Defaults to ingress_cidr_blocks when empty" + description = "List of security group IDs to associate with the instance. Create the security group using the dedicated security group module and pass its ID here" type = list(string) default = [] } From 198446951e768544634e831d9788c9e7f9fd9773 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Fri, 19 Jun 2026 15:13:20 +0100 Subject: [PATCH 040/118] feat(rds): enhance module functionality and documentation - Added support for a new RDS module path in dependabot.yaml. - Created .terraform.lock.hcl for the new RDS module. - Updated README.md to include usage examples for PostgreSQL and Oracle RDS instances, along with security group and secrets manager integration. - Modified context.tf to enable module creation based on the 'enabled' variable. - Introduced locals.tf to define rds_identifier based on provided names. - Updated main.tf to conditionally create RDS resources based on the 'enabled' variable. - Enhanced variables.tf to include custom_name variable for explicit RDS instance naming. - Updated versions.tf to require Terraform version >= 1.13 and AWS provider version >= 6.42. --- .github/dependabot.yaml | 1 + .../modules/rds/.terraform.lock.hcl | 55 ++++ infrastructure/modules/rds/README.md | 305 ++++++++++++++++-- infrastructure/modules/rds/context.tf | 2 + infrastructure/modules/rds/locals.tf | 4 + infrastructure/modules/rds/main.tf | 12 +- infrastructure/modules/rds/variables.tf | 9 +- infrastructure/modules/rds/versions.tf | 4 +- 8 files changed, 356 insertions(+), 36 deletions(-) create mode 100644 infrastructure/modules/rds/.terraform.lock.hcl create mode 100644 infrastructure/modules/rds/locals.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index ed8d5c8a..d3abac03 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -61,6 +61,7 @@ updates: - "infrastructure/modules/rds-gateway-ecs-task" - "infrastructure/modules/rds-instance" - "infrastructure/modules/rds-users" + - "infrastructure/modules/rds" - "infrastructure/modules/s3-bucket" - "infrastructure/modules/s3" - "infrastructure/modules/secrets-manager" diff --git a/infrastructure/modules/rds/.terraform.lock.hcl b/infrastructure/modules/rds/.terraform.lock.hcl new file mode 100644 index 00000000..61bf386e --- /dev/null +++ b/infrastructure/modules/rds/.terraform.lock.hcl @@ -0,0 +1,55 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.14.0, >= 6.28.0, >= 6.42.0" + hashes = [ + "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:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} + +provider "registry.terraform.io/hashicorp/random" { + version = "3.9.0" + constraints = ">= 3.1.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/rds/README.md b/infrastructure/modules/rds/README.md index 1c010544..437988f3 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -2,22 +2,53 @@ Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). -The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring iam role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. -## Fixed controls +## What this module enforces -These values are always enforced and cannot be overridden by callers. - -| Control | Value | Reason | -|---------|-------|--------| -| `publicly_accessible` | `false` | Databases must never be internet-facing | -| `storage_encrypted` | `true` | Encryption at rest is mandatory | -| `copy_tags_to_snapshot` | `true` | Snapshots must carry the same tags as the instance | -| `auto_minor_version_upgrade` | `false` | Teams keep instances in sync with the production engine version | -| `create_db_subnet_group` | `true` | Subnet group is always managed by this module | +|Control|Value|Reason| +|-|-|-| +|`publicly_accessible`|`false`|Databases must never be internet-facing| +|`storage_encrypted`|`true`|Encryption at rest is mandatory| +|`copy_tags_to_snapshot`|`true`|Snapshots must carry the same tags as the instance| +|`auto_minor_version_upgrade`|`false`|Teams keep instances in sync with the production engine version| +|`create_db_subnet_group`|`true`|subnet group is always managed by this module| +|Creation gate|`module.this.enabled`|Prevents all managed RDS resources when disabled| ## Usage +### Minimal PostgreSQL instance + +```hcl +module "postgres_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + service = "bcss" + project = "screening" + environment = "dev" + stack = "database" + workspace = terraform.workspace + + engine = "postgres" + engine_version = "16" + major_engine_version = "16" + family = "postgres16" + + instance_class = "db.t4g.medium" + allocated_storage = 100 + + db_name = "screening" + username = var.rds_master_username + port = 5432 + + password_wo = var.rds_master_password + password_wo_version = 1 + + subnet_ids = data.aws_subnets.private.ids + vpc_security_group_ids = [module.rds_security_group.security_group_id] +} +``` + ### Oracle with a fresh database ```hcl @@ -29,8 +60,9 @@ module "oracle_rds" { environment = var.environment workspace = terraform.workspace - # identity - identifier = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" + # identity (optional) + # If omitted, the module derives a name from context labels. + custom_name = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" # engine engine = "oracle-ee" @@ -117,24 +149,121 @@ module "oracle_rds" { } ``` -The master password ARN is exposed via the `master_user_secret_arn` output. +### With security-group and secrets-manager modules -## Outputs +This example shows the recommended pattern for production use: integrating with the dedicated security-group and secrets-manager modules. -| Name | Description | -|------|-------------| -| `instance_address` | Hostname of the RDS instance (without port) | -| `instance_port` | Port number | -| `instance_endpoint` | Connection endpoint in `host:port` format | -| `instance_id` | RDS instance identifier | -| `instance_arn` | ARN of the RDS instance | -| `instance_resource_id` | RDS resource ID (used for IAM authentication) | -| `master_user_secret_arn` | Secrets Manager ARN for the master password (when `manage_master_user_password = true`) | -| `rds_subnet_group` | Object with `.id` and `.arn` for the DB subnet group | -| `db_subnet_group_id` | DB subnet group name/ID | -| `db_parameter_group_id` | DB parameter group ID | -| `db_option_group_id` | DB option group ID | -| `enhanced_monitoring_iam_role_arn` | Enhanced Monitoring IAM role ARN (empty when `monitoring_interval = 0`) | +```hcl +# Create a security group using the NHS security-group wrapper module +module "rds_security_group" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/security-group?ref=vX.Y.Z"" + + service = "bcss" + project = "screening" + environment = var.environment + workspace = terraform.workspace + + vpc_id = data.aws_vpc.private.id + description = "Security group for RDS database" + + # Allow inbound on Oracle port from application security group + ingress_rules = [ + { + from_port = 1521 + to_port = 1521 + protocol = "tcp" + security_groups = [module.app_security_group.security_group_id] + description = "Oracle from application" + } + ] +} + +# Store the master password in Secrets Manager +module "rds_secret" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/secrets-manager?ref=vX.Y.Z" + + service = "bcss" + project = "screening" + environment = var.environment + workspace = terraform.workspace + name = "rds-master-password" + + secret_string = var.rds_master_password + # Optionally: managed rotation, encryption key, tags, etc. +} + +# Create the RDS instance with the security group and managed password +module "oracle_rds" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws//infrastructure/modules/rds?ref=vX.Y.Z" + + service = "bcss" + project = "screening" + environment = var.environment + workspace = terraform.workspace + stack = "database" + + engine = "oracle-ee" + engine_version = "19" + license_model = "license-included" + major_engine_version = "19" + family = "oracle-ee-19" + character_set_name = "AL32UTF8" + + instance_class = "db.m5.large" + allocated_storage = 500 + storage_type = "gp3" + + db_name = "MYDB" + username = var.rds_master_username + port = 1521 + + # Use RDS Secrets Manager integration for password management + manage_master_user_password = true + # The password is automatically stored in Secrets Manager by RDS + # Retrieve it via module output: module.oracle_rds.master_user_secret_arn + + subnet_ids = data.aws_subnets.private.ids + # Pass the security group created above + vpc_security_group_ids = [module.rds_security_group.security_group_id] + + multi_az = var.environment == "prod" + backup_retention_period = 7 + skip_final_snapshot = false + deletion_protection = var.environment == "prod" + + depends_on = [module.rds_security_group] +} + +# Output the secret arn for application connection strings +output "rds_secret_arn" { + value = module.rds_secret.secret_arn + description = "arn of the RDS master password secret" +} + +output "rds_endpoint" { + value = module.oracle_rds.instance_endpoint + description = "RDS instance endpoint (host:port)" +} +``` + +The master password arn is exposed via the `master_user_secret_arn` output. + +## Conventions + +- Naming and tagging come from shared `context.tf` via `module.this`. +- Identifier resolution order is `custom_name`, then `identifier`, then `module.this.id`. +- Security groups are intentionally caller-managed. This module associates IDs passed via `vpc_security_group_ids`. +- Resource creation is gated by `module.this.enabled`. +- Snapshot tagging is always enabled via `copy_tags_to_snapshot = true`. +- Resource arn values (e.g., `instance_arn`) are exposed as output attributes. +- iam authentication and resource tagging require the instance resource ID. + +## What this module does NOT do + +- Create or manage security groups. +- Allow public internet access to the database instance. +- Disable encryption at rest. +- Enable automatic minor engine upgrades. ## Migration from the local bcss `rds` module @@ -145,3 +274,121 @@ When migrating from `../../modules/rds` in the bcss repo to this shared module, 3. **`password_wo`** — The local module accepted `master_password` as a plain variable (stored in state). This module uses `password_wo` (write-only, not persisted in state). 4. **`deletion_protection`** — Defaults to `true` here (defaults to whatever `var.deletion_protection` was in the local module). Add `#checkov:skip=CKV_AWS_293` to the module call in non-production stacks. 5. **`ignore_changes` lifecycle** — The local module ignored `engine`, `engine_version`, `availability_zone`, `db_subnet_group_name`, and `storage_encrypted` on the `aws_db_instance`. These lifecycle rules are inside the community module and cannot be overridden from a wrapper. Raise this as a known limitation in the migration PR. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [rds](#module\_rds) | terraform-aws-modules/rds/aws | 7.2.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 | +| [allocated\_storage](#input\_allocated\_storage) | The allocated storage in gibibytes | `number` | n/a | yes | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [apply\_immediately](#input\_apply\_immediately) | Apply modifications immediately rather than deferring to the next maintenance window | `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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [backup\_retention\_period](#input\_backup\_retention\_period) | Number of days to retain automated backups. Must be between 0 and 35 | `number` | `7` | no | +| [backup\_window](#input\_backup\_window) | Daily UTC time range for automated backups (e.g. '23:00-23:30'). Must not overlap with maintenance\_window | `string` | `"23:00-23:30"` | no | +| [character\_set\_name](#input\_character\_set\_name) | Oracle character set name. Cannot be changed after creation. Must be null when restoring from a snapshot | `string` | `null` | 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 | +| [custom\_name](#input\_custom\_name) | Optional override name for the RDS instance. Takes precedence over identifier when set. | `string` | `null` | no | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | +| [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | +| [db\_name](#input\_db\_name) | The name of the database to create. Omit to skip initial database creation | `string` | `null` | no | +| [deletion\_protection](#input\_deletion\_protection) | Prevents the DB instance from being deleted when true. Should be true in production | `bool` | `true` | 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 | +| [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) | The database engine to use (e.g. 'oracle-ee', 'postgres', 'mysql') | `string` | n/a | yes | +| [engine\_version](#input\_engine\_version) | The engine version to use | `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 | +| [family](#input\_family) | DB parameter group family (e.g. 'oracle-ee-19', 'postgres16', 'mysql8.0') | `string` | n/a | yes | +| [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 | +| [identifier](#input\_identifier) | Explicit name for the RDS instance. If null, this module derives the name from context labels. | `string` | `null` | no | +| [instance\_class](#input\_instance\_class) | The instance type of the RDS instance (e.g. 'db.m5.large') | `string` | n/a | yes | +| [iops](#input\_iops) | Provisioned IOPS. Required when storage\_type is 'io1' or 'io2' | `number` | `null` | no | +| [kms\_key\_id](#input\_kms\_key\_id) | ARN of the KMS key for storage encryption. If omitted, the default account KMS key is used | `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 | +| [license\_model](#input\_license\_model) | License model for the DB instance. Required for some engines (e.g. Oracle SE1 requires 'license-included') | `string` | `null` | no | +| [maintenance\_window](#input\_maintenance\_window) | Weekly maintenance window (e.g. 'Sun:00:00-Sun:03:00') | `string` | `"Sun:00:00-Sun:03:00"` | no | +| [major\_engine\_version](#input\_major\_engine\_version) | Major engine version for the option group (e.g. '19' for Oracle 19c) | `string` | n/a | yes | +| [manage\_master\_user\_password](#input\_manage\_master\_user\_password) | When true, RDS manages the master password in Secrets Manager. When false, password\_wo must be provided | `bool` | `false` | no | +| [max\_allocated\_storage](#input\_max\_allocated\_storage) | Upper limit for storage autoscaling in gibibytes. Set to 0 to disable autoscaling | `number` | `0` | no | +| [monitoring\_interval](#input\_monitoring\_interval) | Interval in seconds between Enhanced Monitoring data points. Valid values: 0, 1, 5, 10, 15, 30, 60. Set to 0 to disable | `number` | `5` | no | +| [multi\_az](#input\_multi\_az) | Specifies if the RDS instance is Multi-AZ | `bool` | `false` | 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 | +| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [options](#input\_options) | List of option group options to apply. See the community module documentation for the full object shape | `any` | `[]` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [parameters](#input\_parameters) | List of DB parameters to apply to the parameter group |
list(object({
name = string
value = string
apply_method = optional(string)
}))
| `[]` | no | +| [password\_wo](#input\_password\_wo) | Write-only password for the master DB user. Required when manage\_master\_user\_password is false and snapshot\_identifier is not set | `string` | `null` | no | +| [password\_wo\_version](#input\_password\_wo\_version) | Increment this value to trigger a password rotation when password\_wo changes | `number` | `1` | no | +| [performance\_insights\_enabled](#input\_performance\_insights\_enabled) | Enable Performance Insights | `bool` | `true` | no | +| [performance\_insights\_kms\_key\_id](#input\_performance\_insights\_kms\_key\_id) | ARN of the KMS key used to encrypt Performance Insights data. If omitted, the default KMS key is used | `string` | `null` | no | +| [performance\_insights\_retention\_period](#input\_performance\_insights\_retention\_period) | Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31 | `number` | `7` | no | +| [port](#input\_port) | The port on which the DB accepts connections | `number` | n/a | yes | +| [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 | +| [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 | +| [skip\_final\_snapshot](#input\_skip\_final\_snapshot) | If true, no final snapshot is created on deletion. Should be false in production | `bool` | `false` | no | +| [snapshot\_identifier](#input\_snapshot\_identifier) | Snapshot ID to restore the instance from. When set, character\_set\_name must be null | `string` | `null` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [storage\_type](#input\_storage\_type) | One of 'standard', 'gp2', 'gp3', 'io1', or 'io2'. Defaults to 'io1' when iops is set, otherwise 'gp2' | `string` | `null` | no | +| [subnet\_ids](#input\_subnet\_ids) | List of VPC subnet IDs for the DB subnet group | `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 this module path. | `string` | `null` | no | +| [timeouts](#input\_timeouts) | Terraform resource management timeouts for the DB instance |
object({
create = optional(string)
update = optional(string)
delete = optional(string)
})
| `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [username](#input\_username) | Username for the master DB user | `string` | n/a | yes | +| [vpc\_security\_group\_ids](#input\_vpc\_security\_group\_ids) | List of security group IDs to associate with the instance. Create the security group using the dedicated security group module and pass its ID here | `list(string)` | `[]` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [db\_option\_group\_id](#output\_db\_option\_group\_id) | ID of the DB option group | +| [db\_parameter\_group\_id](#output\_db\_parameter\_group\_id) | ID of the DB parameter group | +| [db\_subnet\_group\_id](#output\_db\_subnet\_group\_id) | Name/ID of the DB subnet group | +| [enhanced\_monitoring\_iam\_role\_arn](#output\_enhanced\_monitoring\_iam\_role\_arn) | ARN of the Enhanced Monitoring IAM role. Empty when monitoring\_interval is 0 | +| [instance\_address](#output\_instance\_address) | Hostname of the RDS instance (without port) | +| [instance\_arn](#output\_instance\_arn) | ARN of the RDS instance | +| [instance\_endpoint](#output\_instance\_endpoint) | Connection endpoint for the RDS instance in host:port format | +| [instance\_id](#output\_instance\_id) | Identifier of the RDS instance | +| [instance\_port](#output\_instance\_port) | Port on which the RDS instance accepts connections | +| [instance\_resource\_id](#output\_instance\_resource\_id) | The RDS resource ID (used for IAM authentication and tagging) | +| [master\_user\_secret\_arn](#output\_master\_user\_secret\_arn) | ARN of the Secrets Manager secret for the master user password. Only populated when manage\_master\_user\_password is true | +| [rds\_subnet\_group](#output\_rds\_subnet\_group) | The DB subnet group used by the RDS instance, with id and arn attributes | + + + diff --git a/infrastructure/modules/rds/context.tf b/infrastructure/modules/rds/context.tf index 39d9b945..62befcb0 100644 --- a/infrastructure/modules/rds/context.tf +++ b/infrastructure/modules/rds/context.tf @@ -1,3 +1,4 @@ +# 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 @@ -23,6 +24,7 @@ module "this" { source = "../tags" + enabled = var.enabled service = var.service project = var.project region = var.region diff --git a/infrastructure/modules/rds/locals.tf b/infrastructure/modules/rds/locals.tf new file mode 100644 index 00000000..0f350376 --- /dev/null +++ b/infrastructure/modules/rds/locals.tf @@ -0,0 +1,4 @@ +locals { + # Prefer explicit caller names when provided, otherwise derive from context labels. + rds_identifier = coalesce(var.custom_name, var.identifier, module.this.id) +} diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 45b45d53..32c8cfce 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -14,7 +14,12 @@ module "rds" { source = "terraform-aws-modules/rds/aws" version = "7.2.0" - identifier = var.identifier + create_db_instance = module.this.enabled + create_db_subnet_group = module.this.enabled + create_db_parameter_group = module.this.enabled + create_db_option_group = module.this.enabled + + identifier = local.rds_identifier # Engine engine = var.engine @@ -46,8 +51,7 @@ module "rds" { vpc_security_group_ids = var.vpc_security_group_ids # Subnet group (always managed by this module) - create_db_subnet_group = true - subnet_ids = var.subnet_ids + subnet_ids = var.subnet_ids # Parameter group family = var.family @@ -59,7 +63,7 @@ module "rds" { # Monitoring monitoring_interval = var.monitoring_interval - create_monitoring_role = var.monitoring_interval > 0 + create_monitoring_role = module.this.enabled && var.monitoring_interval > 0 # Availability and backup multi_az = var.multi_az diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index d5fb751f..83bee1b3 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -3,8 +3,15 @@ # ---------------------------------------------------------------------------- variable "identifier" { - description = "The name of the RDS instance" + description = "Explicit name for the RDS instance. If null, this module derives the name from context labels." type = string + default = null +} + +variable "custom_name" { + description = "Optional override name for the RDS instance. Takes precedence over identifier when set." + type = string + default = null } # ---------------------------------------------------------------------------- diff --git a/infrastructure/modules/rds/versions.tf b/infrastructure/modules/rds/versions.tf index aaa5e443..cb30fe5c 100644 --- a/infrastructure/modules/rds/versions.tf +++ b/infrastructure/modules/rds/versions.tf @@ -1,10 +1,10 @@ terraform { - required_version = ">= 1.11.1" + required_version = ">= 1.13" required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.28" + version = ">= 6.42" } } } From 7011cafbee0e29fa93c3c50dcf6b56f5f871fc23 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Tue, 23 Jun 2026 17:26:51 +0100 Subject: [PATCH 041/118] Added doc format fixes --- infrastructure/modules/rds/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 437988f3..0c3aed31 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -2,12 +2,12 @@ Thin NHS wrapper around [`terraform-aws-modules/rds/aws`](https://registry.terraform.io/modules/terraform-aws-modules/rds/aws/latest) (v7.2.0). -The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring iam role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. +The module provisions an RDS DB instance together with its subnet group, parameter group, option group, and (optionally) an Enhanced Monitoring IAM role. The caller is responsible for creating a security group (use the dedicated security group module) and passing its ID via `vpc_security_group_ids`. ## What this module enforces |Control|Value|Reason| -|-|-|-| +|---|---|---| |`publicly_accessible`|`false`|Databases must never be internet-facing| |`storage_encrypted`|`true`|Encryption at rest is mandatory| |`copy_tags_to_snapshot`|`true`|Snapshots must carry the same tags as the instance| @@ -256,7 +256,7 @@ The master password arn is exposed via the `master_user_secret_arn` output. - Resource creation is gated by `module.this.enabled`. - Snapshot tagging is always enabled via `copy_tags_to_snapshot = true`. - Resource arn values (e.g., `instance_arn`) are exposed as output attributes. -- iam authentication and resource tagging require the instance resource ID. +- IAM authentication and resource tagging require the instance resource ID. ## What this module does NOT do From d1fba95df7ae64ae6ebccdb4737f83425810554b Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Wed, 24 Jun 2026 11:41:08 +0100 Subject: [PATCH 042/118] docs(README.md): update README and module metadata for rds --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4ff20c4a..4ec23177 100644 --- a/README.md +++ b/README.md @@ -323,6 +323,7 @@ Rules: | `parameter_store` | — | SSM Parameter Store configuration | | `r53` | terraform-aws-modules/route53/aws | Route 53 DNS Zones, Records, Resolver and Resolver Firewall | | `r53-healthcheck` | — | Route 53 health checks | +| `rds` | terraform-aws-modules/rds/aws | RDS database instance | | `rds-database` | — | RDS database (logical) | | `rds-gateway-ecs-task` | — | RDS gateway ECS task definition | | `rds-instance` | — | RDS instance | From c1675d4a180174b879ae456ffc4ae817d773f8f9 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 24 Jun 2026 14:16:18 +0100 Subject: [PATCH 043/118] Added improvements from PR --- infrastructure/modules/alb/README.md | 13 +++------ infrastructure/modules/alb/main.tf | 35 +++++++++++++++++++------ infrastructure/modules/alb/variables.tf | 12 +++++++++ 3 files changed, 42 insertions(+), 18 deletions(-) diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md index 9533afab..5ece261b 100644 --- a/infrastructure/modules/alb/README.md +++ b/infrastructure/modules/alb/README.md @@ -6,7 +6,6 @@ Thin NHS wrapper around [terraform-aws-modules/alb/aws](https://registry.terrafo | Setting | Value | Reason | |---|---|---| -| `enable_deletion_protection` | `true` | Prevents accidental load balancer deletion via the AWS API | | `drop_invalid_header_fields` | `true` (ALB only) | Prevents HTTP header injection attacks | ## Usage @@ -58,16 +57,8 @@ module "alb" { } } + # HTTP → HTTPS redirect on port 80 is added automatically by this module. listeners = { - http-redirect = { - port = 80 - protocol = "HTTP" - redirect = { - port = "443" - protocol = "HTTPS" - status_code = "HTTP_301" - } - } https = { port = 443 protocol = "HTTPS" @@ -175,6 +166,8 @@ module "nlb" { * The load balancer name is derived from `module.this.id` and cannot be overridden — pass `context`, `name`, and `label_order` to control the generated name. * `internal` defaults to `false` (internet-facing). Set `internal = true` for ALBs and NLBs that should only be reachable from within the VPC. +* `enable_deletion_protection` defaults to `true`. Set it to `false` for non-production environments where the load balancer needs to be freely destroyed. +* HTTP-to-HTTPS redirect on port 80 is added automatically for ALBs (`enable_http_https_redirect = true` by default). Set to `false` if you need custom HTTP listener behaviour or the ALB is not serving HTTPS traffic. Does not apply to NLBs. * Security group rules are caller-supplied via `security_group_ingress_rules` and `security_group_egress_rules`. This supports both ALB (HTTP/HTTPS) and NLB (TCP/TLS) patterns without constraining port numbers. * Access logging is optional. Supply an S3 bucket ARN via `access_logs` to enable it. NHS production environments should always enable access logging. * For NLBs, `drop_invalid_header_fields` is set to `null` automatically — this argument is only valid for ALBs. diff --git a/infrastructure/modules/alb/main.tf b/infrastructure/modules/alb/main.tf index fe03f1a0..6472f303 100644 --- a/infrastructure/modules/alb/main.tf +++ b/infrastructure/modules/alb/main.tf @@ -4,17 +4,35 @@ # Thin NHS wrapper around the community ALB module that enforces # the screening platform's baseline controls: # -# * Deletion protection: always enabled +# * Deletion protection: enabled by default; set enable_deletion_protection = false for non-prod # * Invalid header fields: always dropped (ALB only) +# * HTTP→HTTPS redirect: automatic on port 80 for ALBs (disable with enable_http_https_redirect = false) # * Naming: derived from context labels via module.this.id # * Tagging: all NHS-required tags applied automatically # * Enabled flag: create = module.this.enabled # # Inputs intentionally NOT exposed (hardcoded below): -# - enable_deletion_protection → always true # - drop_invalid_header_fields → always true (ALB); null (NLB) ################################################################ +locals { + # Inject an HTTP → HTTPS redirect listener on port 80 when enabled (ALB only). + # Callers can override by providing their own "http-redirect" key in var.listeners. + http_redirect_listener = var.enable_http_https_redirect && var.load_balancer_type == "application" ? { + http-redirect = { + port = 80 + protocol = "HTTP" + redirect = { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + } : {} + + effective_listeners = merge(local.http_redirect_listener, var.listeners) +} + module "alb" { source = "terraform-aws-modules/alb/aws" version = "10.5.0" @@ -28,11 +46,13 @@ module "alb" { subnets = var.subnets # ---------------------------------------------------------------- - # Security baseline — hardcoded, callers cannot override. + # Security baseline — drop_invalid_header_fields is hardcoded. + # enable_deletion_protection defaults to true; callers may set it to + # false for non-production environments. # drop_invalid_header_fields is ALB-only; pass null for NLB so # the upstream module does not error. # ---------------------------------------------------------------- - enable_deletion_protection = true + enable_deletion_protection = var.enable_deletion_protection drop_invalid_header_fields = var.load_balancer_type == "application" ? true : null # ---------------------------------------------------------------- @@ -48,11 +68,10 @@ module "alb" { access_logs = var.access_logs # ---------------------------------------------------------------- - # Listeners and target groups — passed through as-is. - # Callers define the full listener/target group configuration - # including SSL policies, certificates, health checks, etc. + # Listeners — merged with the automatic HTTP→HTTPS redirect (ALB only). + # Target groups are passed through as-is. # ---------------------------------------------------------------- - listeners = var.listeners + listeners = local.effective_listeners target_groups = var.target_groups # ---------------------------------------------------------------- diff --git a/infrastructure/modules/alb/variables.tf b/infrastructure/modules/alb/variables.tf index e1bbcaf3..a823e7e2 100644 --- a/infrastructure/modules/alb/variables.tf +++ b/infrastructure/modules/alb/variables.tf @@ -108,3 +108,15 @@ variable "web_acl_arn" { default = null description = "ARN of a WAFv2 Web ACL to associate with the load balancer. Only valid for ALB. When null, no WAF association is created." } + +variable "enable_deletion_protection" { + type = bool + default = true + description = "When true, deletion protection is enabled on the load balancer. Set to false for non-production environments where the load balancer needs to be freely destroyed." +} + +variable "enable_http_https_redirect" { + type = bool + default = true + description = "When true, automatically adds a port-80 HTTP-to-HTTPS (301) redirect listener. Only applies when load_balancer_type is 'application'. Set to false if you are defining your own HTTP listener or the ALB is not serving HTTPS traffic." +} From d1c7c5ce623d3093bf5ac22775e722143775fdb5 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 24 Jun 2026 14:48:27 +0100 Subject: [PATCH 044/118] fix(cognito): action feedback on PR --- infrastructure/modules/cognito/main.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/cognito/main.tf b/infrastructure/modules/cognito/main.tf index ad2a6aea..0e8908cc 100644 --- a/infrastructure/modules/cognito/main.tf +++ b/infrastructure/modules/cognito/main.tf @@ -82,7 +82,7 @@ module "cognito" { source = "lgallard/cognito-user-pool/aws" version = "4.0.2" - enabled = module.this.enabled && var.create + enabled = module.this.enabled user_pool_name = local.user_pool_name domain = local.domain_name From 17d272371451d4fdf75a469d062f68ec05a0a481 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 24 Jun 2026 15:06:16 +0100 Subject: [PATCH 045/118] fix(cognito): add validation for `message_action` variable --- infrastructure/modules/cognito/variables.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index b8d99d07..3410c03e 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -84,6 +84,11 @@ variable "message_action" { description = "Message action for bootstrap Cognito user creation. Defaults to SUPPRESS to match the current BCSS stacks." type = string default = "SUPPRESS" + + validation { + condition = contains(["SUPPRESS", "RESEND"], var.message_action) + error_message = "Allowed values: `SUPPRESS`, `RESEND`." + } } variable "acr" { From 430d5714ba2419909397832d807a4278eb145f83 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 24 Jun 2026 15:09:53 +0100 Subject: [PATCH 046/118] fix(cognito): add validation for `deletion_protection` variable --- infrastructure/modules/cognito/variables.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index 3410c03e..3251d7dd 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -20,6 +20,11 @@ variable "deletion_protection" { description = "Deletion protection setting for the user pool. Valid values are ACTIVE and INACTIVE." type = string default = "INACTIVE" + + validation { + condition = contains(["ACTIVE", "INACTIVE"], var.deletion_protection) + error_message = "Allowed values: `ACTIVE`, `INACTIVE`." + } } variable "mfa_configuration" { From 2a82fc853eaf0c6aad7cf2fd97f59ecfc50e69fd Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 24 Jun 2026 15:11:08 +0100 Subject: [PATCH 047/118] fix(cognito): add validation for `mfa_configuration` variable --- infrastructure/modules/cognito/readme.md | 1 + infrastructure/modules/cognito/variables.tf | 5 +++++ infrastructure/modules/cognito/versions.tf | 6 +++++- 3 files changed, 11 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/cognito/readme.md b/infrastructure/modules/cognito/readme.md index 7f40f2c7..5ae8f542 100644 --- a/infrastructure/modules/cognito/readme.md +++ b/infrastructure/modules/cognito/readme.md @@ -116,6 +116,7 @@ Those are intentionally not moved into this shared module. | ---- | ------- | | [terraform](#requirement\_terraform) | >= 1.13 | | [aws](#requirement\_aws) | >= 6.42 | +| [awscc](#requirement\_awscc) | >= 1.89 | ## Providers diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index 3251d7dd..582406da 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -31,6 +31,11 @@ variable "mfa_configuration" { description = "MFA setting for the user pool. Valid values are ON, OFF, or OPTIONAL." type = string default = "OFF" + + validation { + condition = contains(["ON", "OFF", "OPTIONAL"], var.mfa_configuration) + error_message = "Allowed values: `ON`, `OFF`, `OPTIONAL`." + } } variable "attribute_names" { diff --git a/infrastructure/modules/cognito/versions.tf b/infrastructure/modules/cognito/versions.tf index ecde41b7..9fc50fac 100644 --- a/infrastructure/modules/cognito/versions.tf +++ b/infrastructure/modules/cognito/versions.tf @@ -8,7 +8,11 @@ terraform { } awscc = { - source = "hashicorp/awscc" + # Not used directly in this module + # Used by `lgallard/cognito-user-pool/aws` for managed login branding + # Not clear what the minimum version should be + source = "hashicorp/awscc" + version = ">= 1.89" } } } From 0486f7d92503bdc4e17e04d95a1b13bb532968e7 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 24 Jun 2026 17:02:55 +0100 Subject: [PATCH 048/118] docs(cognito): rename readme 1/2 --- infrastructure/modules/cognito/{readme.md => tmp.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename infrastructure/modules/cognito/{readme.md => tmp.md} (100%) diff --git a/infrastructure/modules/cognito/readme.md b/infrastructure/modules/cognito/tmp.md similarity index 100% rename from infrastructure/modules/cognito/readme.md rename to infrastructure/modules/cognito/tmp.md From f36f6bf3dfc82cb6da96091631bffcb804b13737 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 24 Jun 2026 17:03:37 +0100 Subject: [PATCH 049/118] docs(cognito): rename readme 2/2 --- infrastructure/modules/cognito/{tmp.md => README.md} | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename infrastructure/modules/cognito/{tmp.md => README.md} (100%) diff --git a/infrastructure/modules/cognito/tmp.md b/infrastructure/modules/cognito/README.md similarity index 100% rename from infrastructure/modules/cognito/tmp.md rename to infrastructure/modules/cognito/README.md From 0b1379a6644b8ead6b705b30f7027a4d6113433e Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Thu, 25 Jun 2026 08:00:32 +0100 Subject: [PATCH 050/118] docs(cognito): intro --- infrastructure/modules/cognito/README.md | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/infrastructure/modules/cognito/README.md b/infrastructure/modules/cognito/README.md index 5ae8f542..4207f9f9 100644 --- a/infrastructure/modules/cognito/README.md +++ b/infrastructure/modules/cognito/README.md @@ -1,12 +1,10 @@ # Cognito -Thin wrapper around [`lgallard/cognito-user-pool/aws`](https://registry.terraform.io/modules/lgallard/cognito-user-pool/aws/4.0.2) -for the shared-resources stack. +NHS Screening wrapper around the community +[`lgallard/cognito-user-pool/aws`](https://registry.terraform.io/modules/lgallard/cognito-user-pool/aws/4.0.2) +module that enforces the platform's baseline controls and consumes +the shared `context.tf` for naming and tagging. -## Summary - -This module standardises Cognito user pool creation around the preferred upstream -community module while adopting this repository's shared `context.tf` pattern. It keeps the default pool/domain/schema behaviour from the existing bespoke module where practical, but deliberately avoids carrying forward the old Secrets Manager password flow. From fc6e9bbf72f94ebeb2bdd569befc26e44e970851 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Thu, 25 Jun 2026 08:04:20 +0100 Subject: [PATCH 051/118] =?UTF-8?q?docs(cognito):=20consolidate=20what=20m?= =?UTF-8?q?odule=20doesn=E2=80=99t=20do?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infrastructure/modules/cognito/README.md | 25 ++++++------------------ 1 file changed, 6 insertions(+), 19 deletions(-) diff --git a/infrastructure/modules/cognito/README.md b/infrastructure/modules/cognito/README.md index 4207f9f9..5f93898a 100644 --- a/infrastructure/modules/cognito/README.md +++ b/infrastructure/modules/cognito/README.md @@ -25,13 +25,6 @@ Secrets Manager password flow. * Supports optional bootstrap user creation because the current BCSS Cognito stacks still provision initial users during stack deployment -## What this module does not do - -* It does not create or replicate a Secrets Manager password secret -* It does not create the KMS keys or SSM parameters used by the older external - and training stacks; those remain stack-level concerns and can consume this - module's outputs instead - ## Usage ```hcl @@ -83,7 +76,12 @@ module "cognito" { * `attribute_names` produces the same default custom string attributes as the old module * `bootstrap_users` can be used to preserve the current stack behavior where Cognito users are created during deployment -## Intentionally omitted upstream surface +## What this module does NOT do + +* It does not create or replicate a Secrets Manager password secret +* It does not create the KMS keys or SSM parameters used by the older external + and training stacks; those remain stack-level concerns and can consume this + module's outputs instead The wrapper does not directly expose the upstream generic inputs for: @@ -94,17 +92,6 @@ The wrapper does not directly expose the upstream generic inputs for: If those become required later, they can be added back with an explicit shared-resources use case. -## Proven BCSS compatibility notes - -Comparing against the current BCSS stacks showed that the module surface needs to cover: - -* user pool settings such as deletion protection, MFA, schema attributes, and username settings -* one or more OAuth app clients with callback URLs, logout URLs, token settings, and user-existence handling -* optional bootstrap user creation for shared, external, and training environments - -The BCSS stacks also contain stack-specific KMS and SSM parameter resources for some environments. -Those are intentionally not moved into this shared module. - From 7200b794d3584f836df3a79ed787fb995a69a429 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Thu, 25 Jun 2026 16:10:14 +0100 Subject: [PATCH 052/118] fix for failed checks --- infrastructure/modules/cloudwatch/README.md | 2 +- infrastructure/modules/cloudwatch/locals.tf | 2 +- infrastructure/modules/cloudwatch/main.tf | 2 +- infrastructure/modules/cloudwatch/outputs.tf | 2 +- infrastructure/modules/cloudwatch/variables.tf | 2 +- infrastructure/modules/cloudwatch/versions.tf | 2 +- 6 files changed, 6 insertions(+), 6 deletions(-) diff --git a/infrastructure/modules/cloudwatch/README.md b/infrastructure/modules/cloudwatch/README.md index eb529fd5..beaaa762 100644 --- a/infrastructure/modules/cloudwatch/README.md +++ b/infrastructure/modules/cloudwatch/README.md @@ -75,4 +75,4 @@ module "cloudwatch" { - `metric_alarm` and `metric_alarms_by_multiple_dimensions` derive their metric name and namespace from `log_metric_filter`. - `metric_alarm` uses fixed defaults of `period = "60"` and `statistic = "Sum"`. - `metric_alarms_by_multiple_dimensions` uses fixed defaults of `period = "60"` and `statistic = "Sum"`. -- CloudWatch log streams and log metric filters do not support tags directly, so only the submodules that accept tags receive `module.this.tags`. \ No newline at end of file +- CloudWatch log streams and log metric filters do not support tags directly, so only the submodules that accept tags receive `module.this.tags`. diff --git a/infrastructure/modules/cloudwatch/locals.tf b/infrastructure/modules/cloudwatch/locals.tf index f84124ef..bce20729 100644 --- a/infrastructure/modules/cloudwatch/locals.tf +++ b/infrastructure/modules/cloudwatch/locals.tf @@ -24,4 +24,4 @@ locals { metric_alarms_by_multiple_dimensions_metric_name = try(var.log_metric_filter.metric_transformation_name, null) metric_alarms_by_multiple_dimensions_namespace = try(var.log_metric_filter.metric_transformation_namespace, null) -} \ No newline at end of file +} diff --git a/infrastructure/modules/cloudwatch/main.tf b/infrastructure/modules/cloudwatch/main.tf index d9ea95b1..71db3051 100644 --- a/infrastructure/modules/cloudwatch/main.tf +++ b/infrastructure/modules/cloudwatch/main.tf @@ -114,4 +114,4 @@ check "metric_alarms_by_multiple_dimensions_metric_identity" { condition = var.metric_alarms_by_multiple_dimensions == null || (local.metric_alarms_by_multiple_dimensions_metric_name != null && local.metric_alarms_by_multiple_dimensions_namespace != null) error_message = "metric_alarms_by_multiple_dimensions requires metric_name and namespace, either directly on metric_alarms_by_multiple_dimensions or indirectly from log_metric_filter." } -} \ No newline at end of file +} diff --git a/infrastructure/modules/cloudwatch/outputs.tf b/infrastructure/modules/cloudwatch/outputs.tf index b022b4c9..875cdba1 100644 --- a/infrastructure/modules/cloudwatch/outputs.tf +++ b/infrastructure/modules/cloudwatch/outputs.tf @@ -41,4 +41,4 @@ output "cloudwatch_metric_alarm_ids" { output "cloudwatch_metric_alarm_arns" { description = "Map of CloudWatch metric alarm ARNs created by the multiple-dimensions submodule, if configured." value = module.metric_alarms_by_multiple_dimensions.cloudwatch_metric_alarm_arns -} \ No newline at end of file +} diff --git a/infrastructure/modules/cloudwatch/variables.tf b/infrastructure/modules/cloudwatch/variables.tf index 4ff1c4f4..b62540c5 100644 --- a/infrastructure/modules/cloudwatch/variables.tf +++ b/infrastructure/modules/cloudwatch/variables.tf @@ -46,4 +46,4 @@ variable "metric_alarms_by_multiple_dimensions" { dimensions = map(map(string)) }) default = null -} \ No newline at end of file +} diff --git a/infrastructure/modules/cloudwatch/versions.tf b/infrastructure/modules/cloudwatch/versions.tf index d6b7d5f0..cb30fe5c 100644 --- a/infrastructure/modules/cloudwatch/versions.tf +++ b/infrastructure/modules/cloudwatch/versions.tf @@ -7,4 +7,4 @@ terraform { version = ">= 6.42" } } -} \ No newline at end of file +} From a0009451ce810cd2e7ac5a56afb79a4016084026 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Thu, 25 Jun 2026 16:16:00 +0100 Subject: [PATCH 053/118] update Dependabot and CloudWatch module docs --- .github/dependabot.yaml | 1 + infrastructure/modules/cloudwatch/README.md | 4 ++-- .../modules/cloudwatch/variables.tf | 22 +++++++++---------- 3 files changed, 14 insertions(+), 13 deletions(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index ed8d5c8a..25377d7b 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -40,6 +40,7 @@ updates: - "infrastructure/modules/aws-backup-destination" - "infrastructure/modules/aws-backup-source" - "infrastructure/modules/aws-scheduler" + - "infrastructure/modules/cloudwatch" - "infrastructure/modules/cognito" - "infrastructure/modules/cw-firehose-splunk" - "infrastructure/modules/ecr" diff --git a/infrastructure/modules/cloudwatch/README.md b/infrastructure/modules/cloudwatch/README.md index beaaa762..8a385177 100644 --- a/infrastructure/modules/cloudwatch/README.md +++ b/infrastructure/modules/cloudwatch/README.md @@ -1,7 +1,7 @@ # CloudWatch -NHS Screening wrapper around selected submodules from -[terraform-aws-modules/cloudwatch/aws](https://registry.terraform.io/modules/terraform-aws-modules/cloudwatch/aws/latest) +NHS Screening wrapper around selected submodules from the +[terraform-aws-modules CloudWatch module](https://registry.terraform.io/modules/terraform-aws-modules/cloudwatch/aws/latest) that provides a single module entry point for common CloudWatch log and alarm building blocks. ## Included submodules diff --git a/infrastructure/modules/cloudwatch/variables.tf b/infrastructure/modules/cloudwatch/variables.tf index b62540c5..f69cf4b4 100644 --- a/infrastructure/modules/cloudwatch/variables.tf +++ b/infrastructure/modules/cloudwatch/variables.tf @@ -7,22 +7,22 @@ variable "log_group" { description = "Configuration for the CloudWatch log group submodule. Set to null to skip creating a log group." - type = object({}) - default = null + type = object({}) + default = null } variable "log_stream" { description = "Configuration for the CloudWatch log stream submodule. Set to null to skip creating a log stream." - type = object({}) - default = null + type = object({}) + default = null } variable "log_metric_filter" { description = "Configuration for the CloudWatch log metric filter submodule. Set to null to skip creating a log metric filter." type = object({ - pattern = string - metric_transformation_name = string - metric_transformation_namespace = string + pattern = string + metric_transformation_name = string + metric_transformation_namespace = string }) default = null } @@ -40,10 +40,10 @@ variable "metric_alarm" { variable "metric_alarms_by_multiple_dimensions" { description = "Configuration for the CloudWatch metric alarms by multiple dimensions submodule. Set to null to skip creating these alarms." type = object({ - comparison_operator = string - evaluation_periods = number - threshold = number - dimensions = map(map(string)) + comparison_operator = string + evaluation_periods = number + threshold = number + dimensions = map(map(string)) }) default = null } From c5dd71ec5a5797b78999d6dfa56a23115c5cc62c Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Thu, 25 Jun 2026 16:22:23 +0100 Subject: [PATCH 054/118] added cloudwatch metadata in yml file and updated readme --- README.md | 1 + scripts/config/generate-available-modules.yaml | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/README.md b/README.md index bc97b9f3..b062e5b3 100644 --- a/README.md +++ b/README.md @@ -306,6 +306,7 @@ Rules: | `aws-backup-destination` | — | AWS Backup destination vault | | `aws-backup-source` | — | AWS Backup source configuration | | `aws-scheduler` | — | EventBridge Scheduler configuration | +| `cloudwatch` | terraform-aws-modules/cloudwatch/aws | CloudWatch log groups, streams, metric filters, and alarms | | `cognito` | — | Cognito user and identity pools | | `cw-firehose-splunk` | — | CloudWatch logs to Splunk via Firehose | | `ecr` | — | ECR repository with security controls | diff --git a/scripts/config/generate-available-modules.yaml b/scripts/config/generate-available-modules.yaml index 41e5b7b9..5cd9f165 100644 --- a/scripts/config/generate-available-modules.yaml +++ b/scripts/config/generate-available-modules.yaml @@ -29,6 +29,10 @@ aws-scheduler: description: "EventBridge Scheduler configuration" wraps: "—" +cloudwatch: + description: "CloudWatch log groups, streams, metric filters, and alarms" + wraps: "terraform-aws-modules/cloudwatch/aws" + cognito: description: "Cognito user and identity pools" wraps: "—" From c02cf13977be48fecf5ff9f26ab0a508b2de0ef6 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Thu, 25 Jun 2026 16:35:55 +0100 Subject: [PATCH 055/118] fix for failed checks --- README.md | 4 +-- .../modules/cloudwatch/.terraform.lock.hcl | 26 +++++++++++++++++++ 2 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 infrastructure/modules/cloudwatch/.terraform.lock.hcl diff --git a/README.md b/README.md index b062e5b3..2654f990 100644 --- a/README.md +++ b/README.md @@ -301,12 +301,12 @@ Rules: | Module | Wraps | Description | | --- | --- | --- | -| `acm` | terraform-aws-modules/acm/aws | AWS Certificate Manager (ACM) certificate management | +| `acm` | `terraform-aws-modules/acm/aws` | AWS Certificate Manager (ACM) certificate management | | `api-gateway` | — | API Gateway configuration with custom domain and integration | | `aws-backup-destination` | — | AWS Backup destination vault | | `aws-backup-source` | — | AWS Backup source configuration | | `aws-scheduler` | — | EventBridge Scheduler configuration | -| `cloudwatch` | terraform-aws-modules/cloudwatch/aws | CloudWatch log groups, streams, metric filters, and alarms | +| `cloudwatch` | `terraform-aws-modules/cloudwatch/aws` | CloudWatch log groups, streams, metric filters, and alarms | | `cognito` | — | Cognito user and identity pools | | `cw-firehose-splunk` | — | CloudWatch logs to Splunk via Firehose | | `ecr` | — | ECR repository with security controls | diff --git a/infrastructure/modules/cloudwatch/.terraform.lock.hcl b/infrastructure/modules/cloudwatch/.terraform.lock.hcl new file mode 100644 index 00000000..adcb22f9 --- /dev/null +++ b/infrastructure/modules/cloudwatch/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.52.0" + constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" + hashes = [ + "h1:F1r7mTJ289EBvU7Z7XAvWMHUt3VJ3zSFH8SUjLPFiqM=", + "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", + "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", + "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", + "zh:2d5562ed0e7cb0892fb537c7989d25fdde1d0ac1f3a768ba15fa087985f2a0f5", + "zh:40d64f668961a172355c3d11d258e19172ffafb421c40d89266b129ed8f92a5d", + "zh:497792bccc33001247473bc32a148c067982cb3d245b8f3610afb920635d2235", + "zh:8011f9167082af74ed9257582a83d54e889388656597721b2691797f1dd6fb58", + "zh:8b10d1bbca51b0da1e1be0967ce75b039f2d5d86f2ca7339994a92d35cdf47a8", + "zh:9549647dbf7c913512c26fed6badd7bf24a29a36c2bd308536849303a85427da", + "zh:97c4b726cdbe48166f4b9e6a1230312a7933dc19dd139d941ca6aca86706eb33", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a484bc8166278b546bfe53698fa9e0a919dffe3bfda3c8bf8a0fc6811b5b9e66", + "zh:aa96e76bd9f93e5395a553fccec485554a74acae46b3834b1296374eba4f010f", + "zh:cf290ee3d0dfe91b596445f860440d9f18826f7395ff785f83f70d36cedadd1c", + "zh:d56b6a79207663673f66d9ed378d705c1bd1b4d117eeb5e2be3938cf1b75b7be", + "zh:febef068317f8e49bd438ccdf2f54345fc3bc4b80a2699d9ab3c449d7e521d8e", + ] +} From 2a855b7640ab32bcba3b669094aab1bcc9dfecfb Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 25 Jun 2026 16:42:27 +0100 Subject: [PATCH 056/118] Added tflint-ignore-file to silence warnings --- infrastructure/modules/alb/context.tf | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/alb/context.tf b/infrastructure/modules/alb/context.tf index 39d9b945..f813fc8e 100644 --- a/infrastructure/modules/alb/context.tf +++ b/infrastructure/modules/alb/context.tf @@ -1,3 +1,4 @@ +# 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 @@ -47,7 +48,6 @@ module "this" { } # 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" From bb552e6d3d1e7d2ae07195fc65ee44680283509b Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 25 Jun 2026 17:00:40 +0100 Subject: [PATCH 057/118] Added new infrastructure/modules/alb/.terraform.lock.hcl file --- .../modules/alb/.terraform.lock.hcl | 30 +++++++++++++++++++ 1 file changed, 30 insertions(+) create mode 100644 infrastructure/modules/alb/.terraform.lock.hcl diff --git a/infrastructure/modules/alb/.terraform.lock.hcl b/infrastructure/modules/alb/.terraform.lock.hcl new file mode 100644 index 00000000..994ba441 --- /dev/null +++ b/infrastructure/modules/alb/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.52.0" + constraints = ">= 6.14.0, >= 6.28.0, >= 6.42.0" + hashes = [ + "h1:8m5zm0JaUac+YikXZoZDTV7R0iYL+q3IvTL4Ac8GfDA=", + "h1:Dg9X3Gde96OCT3TovLKAKumnR64VqIIQ37m/9NRJ00Y=", + "h1:F1r7mTJ289EBvU7Z7XAvWMHUt3VJ3zSFH8SUjLPFiqM=", + "h1:iXIFHX6zIvG0hoEi1fImeuHG9V4JOop6O6L6f+MOL2c=", + "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", + "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", + "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", + "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", + "zh:2d5562ed0e7cb0892fb537c7989d25fdde1d0ac1f3a768ba15fa087985f2a0f5", + "zh:40d64f668961a172355c3d11d258e19172ffafb421c40d89266b129ed8f92a5d", + "zh:497792bccc33001247473bc32a148c067982cb3d245b8f3610afb920635d2235", + "zh:8011f9167082af74ed9257582a83d54e889388656597721b2691797f1dd6fb58", + "zh:8b10d1bbca51b0da1e1be0967ce75b039f2d5d86f2ca7339994a92d35cdf47a8", + "zh:9549647dbf7c913512c26fed6badd7bf24a29a36c2bd308536849303a85427da", + "zh:97c4b726cdbe48166f4b9e6a1230312a7933dc19dd139d941ca6aca86706eb33", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a484bc8166278b546bfe53698fa9e0a919dffe3bfda3c8bf8a0fc6811b5b9e66", + "zh:aa96e76bd9f93e5395a553fccec485554a74acae46b3834b1296374eba4f010f", + "zh:cf290ee3d0dfe91b596445f860440d9f18826f7395ff785f83f70d36cedadd1c", + "zh:d56b6a79207663673f66d9ed378d705c1bd1b4d117eeb5e2be3938cf1b75b7be", + "zh:febef068317f8e49bd438ccdf2f54345fc3bc4b80a2699d9ab3c449d7e521d8e", + ] +} From fc9ed74277b89561dd2018d33de1074d27c454d6 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 25 Jun 2026 17:26:33 +0100 Subject: [PATCH 058/118] Updated README.md --- infrastructure/modules/alb/README.md | 82 +++++++++++++++++++++++++++- 1 file changed, 79 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md index 5ece261b..e1a97867 100644 --- a/infrastructure/modules/alb/README.md +++ b/infrastructure/modules/alb/README.md @@ -197,10 +197,86 @@ module "nlb" { ## Requirements | Name | Version | -|---|---| -| terraform | >= 1.5.7 | -| aws | >= 6.28 | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [alb](#module\_alb) | terraform-aws-modules/alb/aws | 10.5.0 | +| [this](#module\_this) | ../tags | n/a | + +## Resources + +No resources. + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [access\_logs](#input\_access\_logs) | S3 access log delivery configuration. When null, access logging is disabled. |
object({
bucket = string
prefix = optional(string)
enabled = optional(bool, true)
})
| `null` | no | +| [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 | +| [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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | +| [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 | +| [enable\_deletion\_protection](#input\_enable\_deletion\_protection) | When true, deletion protection is enabled on the load balancer. Set to false for non-production environments where the load balancer needs to be freely destroyed. | `bool` | `true` | no | +| [enable\_http\_https\_redirect](#input\_enable\_http\_https\_redirect) | When true, automatically adds a port-80 HTTP-to-HTTPS (301) redirect listener. Only applies when load\_balancer\_type is 'application'. Set to false if you are defining your own HTTP listener or the ALB is not serving HTTPS traffic. | `bool` | `true` | no | +| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | +| [internal](#input\_internal) | When true, the load balancer is internal (private). When false, it is internet-facing. Defaults to false. | `bool` | `false` | 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 | +| [listeners](#input\_listeners) | Map of listener configurations to create. Passed directly to the upstream module.
For ALB, define HTTPS and HTTP listeners here. For NLB, define TCP/TLS listeners.
See https://registry.terraform.io/modules/terraform-aws-modules/alb/aws/latest
for full schema documentation. | `any` | `{}` | no | +| [load\_balancer\_type](#input\_load\_balancer\_type) | Type of load balancer to create. Either 'application' (ALB) or 'network' (NLB). | `string` | `"application"` | 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 | +| [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 | +| [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 | +| [security\_group\_egress\_rules](#input\_security\_group\_egress\_rules) | Map of egress rules to add to the load balancer security group.
Example:
security\_group\_egress\_rules = {
https\_out = {
from\_port = 443
to\_port = 443
ip\_protocol = "tcp"
cidr\_ipv4 = "0.0.0.0/0"
}
} | `any` | `{}` | no | +| [security\_group\_ingress\_rules](#input\_security\_group\_ingress\_rules) | Map of ingress rules to add to the load balancer security group.
Each key is a logical name; each value is an object describing the rule.
Example:
security\_group\_ingress\_rules = {
https = {
from\_port = 443
to\_port = 443
ip\_protocol = "tcp"
description = "HTTPS from internet"
cidr\_ipv4 = "0.0.0.0/0"
}
} | `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 | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [subnets](#input\_subnets) | List of subnet IDs to attach to the load balancer. For internet-facing ALBs, use public subnets. | `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 | +| [target\_groups](#input\_target\_groups) | Map of target group configurations to create. Passed directly to the upstream module.
See https://registry.terraform.io/modules/terraform-aws-modules/alb/aws/latest
for full schema documentation. | `any` | `{}` | no | +| [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [vpc\_id](#input\_vpc\_id) | ID of the VPC in which the load balancer security group will be created. | `string` | n/a | yes | +| [web\_acl\_arn](#input\_web\_acl\_arn) | ARN of a WAFv2 Web ACL to associate with the load balancer. Only valid for ALB. When null, no WAF association is created. | `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 | +| ---- | ----------- | +| [arn](#output\_arn) | ARN of the load balancer. Used by WAF to associate a Web ACL. | +| [arn\_suffix](#output\_arn\_suffix) | ARN suffix of the load balancer. Used with CloudWatch metrics. | +| [dns\_name](#output\_dns\_name) | DNS name of the load balancer. Used by Route53 alias records. | +| [id](#output\_id) | ID of the load balancer (same as ARN). | +| [listeners](#output\_listeners) | Map of listeners created and their attributes. ECS tasks use this for depends\_on. | +| [security\_group\_arn](#output\_security\_group\_arn) | ARN of the security group created for the load balancer. | +| [security\_group\_id](#output\_security\_group\_id) | ID of the security group created for the load balancer. | +| [target\_groups](#output\_target\_groups) | Map of target groups created and their attributes. ECS tasks reference target\_group ARNs from here. | +| [zone\_id](#output\_zone\_id) | Hosted zone ID of the load balancer. Used by Route53 alias records. | From 59068e0cb255b961cb5f0e662e13a1041a6a13d8 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 25 Jun 2026 17:42:44 +0100 Subject: [PATCH 059/118] chore: update Dependabot configuration --- .github/dependabot.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index ed8d5c8a..8a5e7c01 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -36,6 +36,7 @@ updates: - package-ecosystem: "terraform" directories: - "infrastructure/modules/acm" + - "infrastructure/modules/alb" - "infrastructure/modules/api-gateway" - "infrastructure/modules/aws-backup-destination" - "infrastructure/modules/aws-backup-source" From 8ad5fc2efe331b018bf9ddc4cbc1c86c9b401c66 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 25 Jun 2026 17:45:03 +0100 Subject: [PATCH 060/118] docs: update Available modules section --- README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/README.md b/README.md index 4ff20c4a..3259f515 100644 --- a/README.md +++ b/README.md @@ -302,6 +302,7 @@ Rules: | Module | Wraps | Description | | --- | --- | --- | | `acm` | terraform-aws-modules/acm/aws | AWS Certificate Manager (ACM) certificate management | +| `alb` | — | — | | `api-gateway` | — | API Gateway configuration with custom domain and integration | | `aws-backup-destination` | — | AWS Backup destination vault | | `aws-backup-source` | — | AWS Backup source configuration | From 0c6f41e4c3901f43f328318b718428b2d1c31471 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Fri, 26 Jun 2026 08:46:22 +0100 Subject: [PATCH 061/118] fix for failed checks. --- README.md | 6 ++++-- infrastructure/modules/cloudwatch/.terraform.lock.hcl | 4 ++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 2654f990..2ee881f2 100644 --- a/README.md +++ b/README.md @@ -298,15 +298,16 @@ Rules: ## Available modules + | Module | Wraps | Description | | --- | --- | --- | -| `acm` | `terraform-aws-modules/acm/aws` | AWS Certificate Manager (ACM) certificate management | +| `acm` | terraform-aws-modules/acm/aws | AWS Certificate Manager (ACM) certificate management | | `api-gateway` | — | API Gateway configuration with custom domain and integration | | `aws-backup-destination` | — | AWS Backup destination vault | | `aws-backup-source` | — | AWS Backup source configuration | | `aws-scheduler` | — | EventBridge Scheduler configuration | -| `cloudwatch` | `terraform-aws-modules/cloudwatch/aws` | CloudWatch log groups, streams, metric filters, and alarms | +| `cloudwatch` | terraform-aws-modules/cloudwatch/aws | CloudWatch log groups, streams, metric filters, and alarms | | `cognito` | — | Cognito user and identity pools | | `cw-firehose-splunk` | — | CloudWatch logs to Splunk via Firehose | | `ecr` | — | ECR repository with security controls | @@ -342,6 +343,7 @@ Rules: | `waf` | — | WAF web ACL with rules | + ## Pre-commit hooks diff --git a/infrastructure/modules/cloudwatch/.terraform.lock.hcl b/infrastructure/modules/cloudwatch/.terraform.lock.hcl index adcb22f9..82c67711 100644 --- a/infrastructure/modules/cloudwatch/.terraform.lock.hcl +++ b/infrastructure/modules/cloudwatch/.terraform.lock.hcl @@ -5,7 +5,11 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.52.0" constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" hashes = [ + "h1:8m5zm0JaUac+YikXZoZDTV7R0iYL+q3IvTL4Ac8GfDA=", + "h1:Dg9X3Gde96OCT3TovLKAKumnR64VqIIQ37m/9NRJ00Y=", "h1:F1r7mTJ289EBvU7Z7XAvWMHUt3VJ3zSFH8SUjLPFiqM=", + "h1:iXIFHX6zIvG0hoEi1fImeuHG9V4JOop6O6L6f+MOL2c=", + "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", From 5a65b6e766760a3d56f40cd53ecf8bbc805e0bbe Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Fri, 26 Jun 2026 09:29:00 +0100 Subject: [PATCH 062/118] added readme --- infrastructure/modules/cloudwatch/README.md | 87 +++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/infrastructure/modules/cloudwatch/README.md b/infrastructure/modules/cloudwatch/README.md index 8a385177..89301990 100644 --- a/infrastructure/modules/cloudwatch/README.md +++ b/infrastructure/modules/cloudwatch/README.md @@ -76,3 +76,90 @@ module "cloudwatch" { - `metric_alarm` uses fixed defaults of `period = "60"` and `statistic = "Sum"`. - `metric_alarms_by_multiple_dimensions` uses fixed defaults of `period = "60"` and `statistic = "Sum"`. - CloudWatch log streams and log metric filters do not support tags directly, so only the submodules that accept tags receive `module.this.tags`. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [log\_group](#module\_log\_group) | terraform-aws-modules/cloudwatch/aws//modules/log-group | 5.7.2 | +| [log\_metric\_filter](#module\_log\_metric\_filter) | terraform-aws-modules/cloudwatch/aws//modules/log-metric-filter | 5.7.2 | +| [log\_stream](#module\_log\_stream) | terraform-aws-modules/cloudwatch/aws//modules/log-stream | 5.7.2 | +| [metric\_alarm](#module\_metric\_alarm) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarm | 5.7.2 | +| [metric\_alarms\_by\_multiple\_dimensions](#module\_metric\_alarms\_by\_multiple\_dimensions) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions | 5.7.2 | +| [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 | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | 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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | +| [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 | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | +| [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\_group](#input\_log\_group) | Configuration for the CloudWatch log group submodule. Set to null to skip creating a log group. | `object({})` | `null` | no | +| [log\_metric\_filter](#input\_log\_metric\_filter) | Configuration for the CloudWatch log metric filter submodule. Set to null to skip creating a log metric filter. |
object({
pattern = string
metric_transformation_name = string
metric_transformation_namespace = string
})
| `null` | no | +| [log\_stream](#input\_log\_stream) | Configuration for the CloudWatch log stream submodule. Set to null to skip creating a log stream. | `object({})` | `null` | no | +| [metric\_alarm](#input\_metric\_alarm) | Configuration for the CloudWatch metric alarm submodule. Set to null to skip creating a metric alarm. |
object({
comparison_operator = string
evaluation_periods = number
threshold = number
})
| `null` | no | +| [metric\_alarms\_by\_multiple\_dimensions](#input\_metric\_alarms\_by\_multiple\_dimensions) | Configuration for the CloudWatch metric alarms by multiple dimensions submodule. Set to null to skip creating these alarms. |
object({
comparison_operator = string
evaluation_periods = number
threshold = number
dimensions = map(map(string))
})
| `null` | 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 | +| [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 | +| [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 | +| [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 | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [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 this module path. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | 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 CloudWatch log group, if created. | +| [cloudwatch\_log\_group\_name](#output\_cloudwatch\_log\_group\_name) | Name of the CloudWatch log group, if created. | +| [cloudwatch\_log\_metric\_filter\_id](#output\_cloudwatch\_log\_metric\_filter\_id) | The name of the CloudWatch log metric filter, if created. | +| [cloudwatch\_log\_stream\_arn](#output\_cloudwatch\_log\_stream\_arn) | ARN of the CloudWatch log stream, if created. | +| [cloudwatch\_log\_stream\_name](#output\_cloudwatch\_log\_stream\_name) | Name of the CloudWatch log stream, if created. | +| [cloudwatch\_metric\_alarm\_arn](#output\_cloudwatch\_metric\_alarm\_arn) | The ARN of the CloudWatch metric alarm, if created. | +| [cloudwatch\_metric\_alarm\_arns](#output\_cloudwatch\_metric\_alarm\_arns) | Map of CloudWatch metric alarm ARNs created by the multiple-dimensions submodule, if configured. | +| [cloudwatch\_metric\_alarm\_id](#output\_cloudwatch\_metric\_alarm\_id) | The ID of the CloudWatch metric alarm, if created. | +| [cloudwatch\_metric\_alarm\_ids](#output\_cloudwatch\_metric\_alarm\_ids) | Map of CloudWatch metric alarm IDs created by the multiple-dimensions submodule, if configured. | + + + From 45bce9b4b12c2355ee5a8365666f9991929df609 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Tue, 30 Jun 2026 11:44:28 +0100 Subject: [PATCH 063/118] fix(ecs-service): update default enable_fault_injection value (code review) --- README.md | 1 + infrastructure/modules/ecs-service/README.md | 2 +- infrastructure/modules/ecs-service/variables.tf | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index bc97b9f3..26074daa 100644 --- a/README.md +++ b/README.md @@ -310,6 +310,7 @@ Rules: | `cw-firehose-splunk` | — | CloudWatch logs to Splunk via Firehose | | `ecr` | — | ECR repository with security controls | | `ecs-cluster` | terraform-aws-modules/ecs/aws//modules/cluster | ECS Fargate cluster | +| `ecs-service` | terraform-aws-modules/ecs/aws//modules/service | ECS service and task definition | | `elasticache` | — | ElastiCache cluster (Redis/Memcached) | | `github-config` | — | GitHub OIDC provider and runner configuration | | `guardduty` | — | GuardDuty threat detection | diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 98a686e5..52395002 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -334,7 +334,7 @@ No resources. | [enable\_autoscaling](#input\_enable\_autoscaling) | Determines whether to enable autoscaling for the service | `bool` | `true` | no | | [enable\_ecs\_managed\_tags](#input\_enable\_ecs\_managed\_tags) | Specifies whether to enable Amazon ECS managed tags for the tasks within the service | `bool` | `true` | no | | [enable\_execute\_command](#input\_enable\_execute\_command) | Specifies whether to enable Amazon ECS Exec for the tasks within the service | `bool` | `false` | no | -| [enable\_fault\_injection](#input\_enable\_fault\_injection) | Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false` | `bool` | `null` | no | +| [enable\_fault\_injection](#input\_enable\_fault\_injection) | Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false` | `bool` | `false` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | | [ephemeral\_storage](#input\_ephemeral\_storage) | The amount of ephemeral storage to allocate for the task. This parameter is used to expand the total amount of ephemeral storage available, beyond the default amount, for tasks hosted on AWS Fargate |
object({
size_in_gib = number
})
| `null` | no | diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index bdb6ed0c..fa5ce03c 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -510,7 +510,7 @@ variable "enable_execute_command" { variable "enable_fault_injection" { description = "Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false`" type = bool - default = null + default = false } variable "ephemeral_storage" { From b8afda39ed6ae98eade66938c4bb345a4aa3af3e Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Tue, 30 Jun 2026 11:48:49 +0100 Subject: [PATCH 064/118] fix(ecs-service): set default propagate_tags value and add validation (code review) --- infrastructure/modules/ecs-service/README.md | 2 +- infrastructure/modules/ecs-service/variables.tf | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 52395002..774d3c82 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -376,7 +376,7 @@ No resources. | [placement\_constraints](#input\_placement\_constraints) | Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the service, see `task_definition_placement_constraints` for setting at the task definition |
map(object({
expression = optional(string)
type = string
}))
| `null` | no | | [platform\_version](#input\_platform\_version) | Platform version on which to run your service. Only applicable for `launch_type` set to `FARGATE`. Defaults to `LATEST` | `string` | `null` | 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 | -| [propagate\_tags](#input\_propagate\_tags) | Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION` | `string` | `null` | no | +| [propagate\_tags](#input\_propagate\_tags) | Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION` | `string` | `"TASK_DEFINITION"` | no | | [proxy\_configuration](#input\_proxy\_configuration) | Configuration block for the App Mesh proxy |
object({
container_name = string
properties = optional(map(string))
type = optional(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 | diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index fa5ce03c..20b6619a 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -735,7 +735,13 @@ variable "platform_version" { variable "propagate_tags" { description = "Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION`" type = string - default = null + default = "TASK_DEFINITION" + nullable = false + + validation { + condition = contains(["SERVICE", "TASK_DEFINITION"], var.propagate_tags) + error_message = "propagate_tags must be one of \"SERVICE\" or \"TASK_DEFINITION\"." + } } variable "proxy_configuration" { From 5a78d3cc5ada537c72539d13a36d770713f57294 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Tue, 30 Jun 2026 13:00:50 +0100 Subject: [PATCH 065/118] fix(ecs-cluster): add cluster_arn validation --- infrastructure/modules/ecs-service/variables.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 20b6619a..88905d0a 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -233,6 +233,11 @@ variable "cluster_arn" { description = "ARN of the ECS cluster where the resources will be provisioned" type = string default = "" + + validation { + condition = var.cluster_arn != "" || var.create_service == false || module.this.enabled == false + error_message = "cluster_arn must be provided if we create a service" + } } variable "container_definitions" { From 3363b2d5ebad0953556ebbc1f51e788942e7e46d Mon Sep 17 00:00:00 2001 From: Pira-nhs Date: Tue, 30 Jun 2026 22:49:18 +0100 Subject: [PATCH 066/118] feat(modules): add description and wraps for alb module --- README.md | 2 +- scripts/config/generate-available-modules.yaml | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 59c13cf9..5268c3a0 100644 --- a/README.md +++ b/README.md @@ -302,7 +302,7 @@ Rules: | Module | Wraps | Description | | --- | --- | --- | | `acm` | terraform-aws-modules/acm/aws | AWS Certificate Manager (ACM) certificate management | -| `alb` | — | — | +| `alb` | terraform-aws-modules/alb/aws | Application / Network Load Balancer with security baseline | | `api-gateway` | — | API Gateway configuration with custom domain and integration | | `aws-backup-destination` | — | AWS Backup destination vault | | `aws-backup-source` | — | AWS Backup source configuration | diff --git a/scripts/config/generate-available-modules.yaml b/scripts/config/generate-available-modules.yaml index 41e5b7b9..1f2a436e 100644 --- a/scripts/config/generate-available-modules.yaml +++ b/scripts/config/generate-available-modules.yaml @@ -13,6 +13,10 @@ acm: description: "AWS Certificate Manager (ACM) certificate management" wraps: "terraform-aws-modules/acm/aws" +alb: + description: "Application / Network Load Balancer with security baseline" + wraps: "terraform-aws-modules/alb/aws" + api-gateway: description: "API Gateway configuration with custom domain and integration" wraps: "—" From b3afe53cd75d826f6f20a9c62c1afd2ac09104f6 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 1 Jul 2026 08:42:31 +0100 Subject: [PATCH 067/118] docs(ecs-service): add corrections to readme --- infrastructure/modules/ecs-service/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 774d3c82..d07c78e4 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -255,7 +255,7 @@ module "legacy_service" { - Create an ECS cluster. Use the `ecs-cluster` module and pass the ARN via `cluster_arn`. -- Create VPCs, subnets, or security groups. The caller is responsible for +- Create VPCs or subnets. The caller is responsible for networking; pass existing security group IDs via `security_group_ids` or let the module create one via `create_security_group = true`. - Create load balancers or target groups. Configure these separately and pass @@ -264,8 +264,6 @@ module "legacy_service" { - Manage KMS encryption at the ECS service level. Task-level secrets encryption is handled via the task execution IAM role and the `secrets-manager` or `parameter_store` modules. -- Create CloudWatch log groups. Define these in your container definitions or - provision them separately. From f0ec28723224e43e010bccb1a11a1c7af1c1a7a6 Mon Sep 17 00:00:00 2001 From: Pira-nhs Date: Wed, 1 Jul 2026 14:35:43 +0100 Subject: [PATCH 068/118] feat(cloudwatch): seperate CW modules to submodule: cloudwatch-log-metric-filter, cloudwatch-logs and cloudwatch-metric-alarm --- .github/dependabot.yaml | 3 + README.md | 3 + .../.terraform.lock.hcl | 26 ++ .../cloudwatch-log-metric-filter/README.md | 164 ++++++++ .../cloudwatch-log-metric-filter/context.tf | 376 ++++++++++++++++++ .../cloudwatch-log-metric-filter/locals.tf | 4 + .../cloudwatch-log-metric-filter/main.tf | 32 ++ .../cloudwatch-log-metric-filter/outputs.tf | 14 + .../cloudwatch-log-metric-filter/variables.tf | 37 ++ .../cloudwatch-log-metric-filter/versions.tf | 10 + .../cloudwatch-logs/.terraform.lock.hcl | 26 ++ .../modules/cloudwatch-logs/README.md | 161 ++++++++ .../modules/cloudwatch-logs/context.tf | 376 ++++++++++++++++++ .../modules/cloudwatch-logs/locals.tf | 4 + .../modules/cloudwatch-logs/main.tf | 43 ++ .../modules/cloudwatch-logs/outputs.tf | 19 + .../modules/cloudwatch-logs/variables.tf | 35 ++ .../modules/cloudwatch-logs/versions.tf | 10 + .../.terraform.lock.hcl | 26 ++ .../modules/cloudwatch-metric-alarm/README.md | 184 +++++++++ .../cloudwatch-metric-alarm/context.tf | 376 ++++++++++++++++++ .../modules/cloudwatch-metric-alarm/locals.tf | 4 + .../modules/cloudwatch-metric-alarm/main.tf | 71 ++++ .../cloudwatch-metric-alarm/outputs.tf | 19 + .../cloudwatch-metric-alarm/variables.tf | 65 +++ .../cloudwatch-metric-alarm/versions.tf | 10 + .../config/generate-available-modules.yaml | 12 + 27 files changed, 2110 insertions(+) create mode 100644 infrastructure/modules/cloudwatch-log-metric-filter/.terraform.lock.hcl create mode 100644 infrastructure/modules/cloudwatch-log-metric-filter/README.md create mode 100644 infrastructure/modules/cloudwatch-log-metric-filter/context.tf create mode 100644 infrastructure/modules/cloudwatch-log-metric-filter/locals.tf create mode 100644 infrastructure/modules/cloudwatch-log-metric-filter/main.tf create mode 100644 infrastructure/modules/cloudwatch-log-metric-filter/outputs.tf create mode 100644 infrastructure/modules/cloudwatch-log-metric-filter/variables.tf create mode 100644 infrastructure/modules/cloudwatch-log-metric-filter/versions.tf create mode 100644 infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl create mode 100644 infrastructure/modules/cloudwatch-logs/README.md create mode 100644 infrastructure/modules/cloudwatch-logs/context.tf create mode 100644 infrastructure/modules/cloudwatch-logs/locals.tf create mode 100644 infrastructure/modules/cloudwatch-logs/main.tf create mode 100644 infrastructure/modules/cloudwatch-logs/outputs.tf create mode 100644 infrastructure/modules/cloudwatch-logs/variables.tf create mode 100644 infrastructure/modules/cloudwatch-logs/versions.tf create mode 100644 infrastructure/modules/cloudwatch-metric-alarm/.terraform.lock.hcl create mode 100644 infrastructure/modules/cloudwatch-metric-alarm/README.md create mode 100644 infrastructure/modules/cloudwatch-metric-alarm/context.tf create mode 100644 infrastructure/modules/cloudwatch-metric-alarm/locals.tf create mode 100644 infrastructure/modules/cloudwatch-metric-alarm/main.tf create mode 100644 infrastructure/modules/cloudwatch-metric-alarm/outputs.tf create mode 100644 infrastructure/modules/cloudwatch-metric-alarm/variables.tf create mode 100644 infrastructure/modules/cloudwatch-metric-alarm/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 25377d7b..899c7e8f 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -40,6 +40,9 @@ updates: - "infrastructure/modules/aws-backup-destination" - "infrastructure/modules/aws-backup-source" - "infrastructure/modules/aws-scheduler" + - "infrastructure/modules/cloudwatch-log-metric-filter" + - "infrastructure/modules/cloudwatch-logs" + - "infrastructure/modules/cloudwatch-metric-alarm" - "infrastructure/modules/cloudwatch" - "infrastructure/modules/cognito" - "infrastructure/modules/cw-firehose-splunk" diff --git a/README.md b/README.md index 2ee881f2..194a26eb 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,9 @@ Rules: | `aws-backup-source` | — | AWS Backup source configuration | | `aws-scheduler` | — | EventBridge Scheduler configuration | | `cloudwatch` | terraform-aws-modules/cloudwatch/aws | CloudWatch log groups, streams, metric filters, and alarms | +| `cloudwatch-log-metric-filter` | terraform-aws-modules/cloudwatch/aws | CloudWatch log metric filters (emits metrics from log patterns) | +| `cloudwatch-logs` | terraform-aws-modules/cloudwatch/aws | CloudWatch log groups and streams | +| `cloudwatch-metric-alarm` | terraform-aws-modules/cloudwatch/aws | CloudWatch metric alarms (single and multi-dimension) | | `cognito` | — | Cognito user and identity pools | | `cw-firehose-splunk` | — | CloudWatch logs to Splunk via Firehose | | `ecr` | — | ECR repository with security controls | diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/.terraform.lock.hcl b/infrastructure/modules/cloudwatch-log-metric-filter/.terraform.lock.hcl new file mode 100644 index 00000000..4657683f --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.52.0" + constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" + hashes = [ + "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", + "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", + "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", + "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", + "zh:2d5562ed0e7cb0892fb537c7989d25fdde1d0ac1f3a768ba15fa087985f2a0f5", + "zh:40d64f668961a172355c3d11d258e19172ffafb421c40d89266b129ed8f92a5d", + "zh:497792bccc33001247473bc32a148c067982cb3d245b8f3610afb920635d2235", + "zh:8011f9167082af74ed9257582a83d54e889388656597721b2691797f1dd6fb58", + "zh:8b10d1bbca51b0da1e1be0967ce75b039f2d5d86f2ca7339994a92d35cdf47a8", + "zh:9549647dbf7c913512c26fed6badd7bf24a29a36c2bd308536849303a85427da", + "zh:97c4b726cdbe48166f4b9e6a1230312a7933dc19dd139d941ca6aca86706eb33", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a484bc8166278b546bfe53698fa9e0a919dffe3bfda3c8bf8a0fc6811b5b9e66", + "zh:aa96e76bd9f93e5395a553fccec485554a74acae46b3834b1296374eba4f010f", + "zh:cf290ee3d0dfe91b596445f860440d9f18826f7395ff785f83f70d36cedadd1c", + "zh:d56b6a79207663673f66d9ed378d705c1bd1b4d117eeb5e2be3938cf1b75b7be", + "zh:febef068317f8e49bd438ccdf2f54345fc3bc4b80a2699d9ab3c449d7e521d8e", + ] +} diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/README.md b/infrastructure/modules/cloudwatch-log-metric-filter/README.md new file mode 100644 index 00000000..3d824d96 --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/README.md @@ -0,0 +1,164 @@ +# CloudWatch Log Metric Filter + +NHS Screening wrapper around the [terraform-aws-modules/CloudWatch/aws](https://registry.terraform.io/modules/terraform-aws-modules/cloudwatch/aws) `log-metric-filter` submodule that enforces screening platform baseline controls. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Naming | Filter name is derived from context + metric name | +| Log group dependency | Requires existing log group name | +| Metric emission | Triggered by pattern matching against log events | +| Namespace resolution | Defaults to log group name if not specified | +| Creation gate | Gated via `module.this.enabled` | + +## Usage + +### Basic: Filter and emit metric + +```hcl +module "error_filter" { + source = "../../modules/cloudwatch-log-metric-filter" + + context = module.this.context + stack = "app" + name = "errors" + + log_group_name = "/aws/ecs/app-service" + pattern = "ERROR" + metric_transformation_name = "ErrorCount" + metric_transformation_namespace = "BCSS/Application" +} +``` + +### With custom namespace + +```hcl +module "high_memory_filter" { + source = "../../modules/cloudwatch-log-metric-filter" + + context = module.this.context + + log_group_name = module.logs.cloudwatch_log_group_name + pattern = "[heap_usage > 0.8]" + metric_transformation_name = "HighMemoryEvents" + metric_transformation_namespace = "BCSS/Performance" + metric_transformation_unit = "Count" + metric_transformation_default_value = 0 +} + +# Reference metric for alarm +module "memory_alarm" { + source = "../../modules/cloudwatch-metric-alarm" + + context = module.this.context + + metric_alarm = { + metric_name = module.high_memory_filter.metric_name + namespace = module.high_memory_filter.metric_namespace + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + threshold = 5 + } + + alarm_actions = [module.sns.topic_arn] +} +``` + +### Namespace defaults to log group name + +```hcl +module "throughput_filter" { + source = "../../modules/cloudwatch-log-metric-filter" + + context = module.this.context + + log_group_name = "/aws/lambda/processor" + pattern = "COMPLETED" + metric_transformation_name = "ProcessedItems" + # Namespace will default to "/aws/lambda/processor" +} +``` + +## Conventions + +- Filter name is `{context.id}-{metric_name}` for clarity. +- Metric name must be non-empty (validation enforced). +- `log_group_name` is required; module will not query or discover it. +- If `metric_transformation_namespace` is not specified, it defaults to the log group name for simplicity. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [log\_metric\_filter](#module\_log\_metric\_filter) | terraform-aws-modules/cloudwatch/aws//modules/log-metric-filter | 5.7.2 | +| [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 | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | 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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | +| [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 | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | +| [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\_group\_name](#input\_log\_group\_name) | Name of the CloudWatch log group to monitor. Required. | `string` | n/a | yes | +| [metric\_transformation\_name](#input\_metric\_transformation\_name) | Name of the metric to emit (e.g., 'ErrorCount'). Will be prefixed with log group name. | `string` | n/a | yes | +| [metric\_transformation\_namespace](#input\_metric\_transformation\_namespace) | CloudWatch namespace for the metric (e.g., 'BCSS/Application'). Defaults to log group name if empty. | `string` | `""` | 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 | +| [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 | +| [pattern](#input\_pattern) | Log pattern to filter on (e.g., 'ERROR', '[ERROR]', etc). Empty string matches all events. | `string` | `""` | 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 | +| [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 | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [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 this module path. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | 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\_metric\_filter\_id](#output\_cloudwatch\_log\_metric\_filter\_id) | ID of the CloudWatch log metric filter. | +| [metric\_name](#output\_metric\_name) | Name of the metric emitted by this filter (for downstream alarm reference). | +| [metric\_namespace](#output\_metric\_namespace) | CloudWatch namespace where the metric is emitted. | + + + diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/context.tf b/infrastructure/modules/cloudwatch-log-metric-filter/context.tf new file mode 100644 index 00000000..62befcb0 --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/context.tf @@ -0,0 +1,376 @@ +# 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" { + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/locals.tf b/infrastructure/modules/cloudwatch-log-metric-filter/locals.tf new file mode 100644 index 00000000..7f4753eb --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/locals.tf @@ -0,0 +1,4 @@ +locals { + metric_namespace = var.metric_transformation_namespace != "" ? var.metric_transformation_namespace : var.log_group_name + filter_name = format("%s-%s", module.this.id, var.metric_transformation_name) +} diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/main.tf b/infrastructure/modules/cloudwatch-log-metric-filter/main.tf new file mode 100644 index 00000000..d51d1b89 --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/main.tf @@ -0,0 +1,32 @@ +################################################################ +# CloudWatch Log Metric Filter +# +# Thin NHS wrapper around the community CloudWatch log-metric-filter +# submodule that enforces screening platform baseline controls: +# +# * Naming: derived from context + metric name +# * Namespace: configurable or defaults to log group name +# * Metric emission: triggered by log pattern matching +# * Enabled flag: create = module.this.enabled +################################################################ + +module "log_metric_filter" { + source = "terraform-aws-modules/cloudwatch/aws//modules/log-metric-filter" + version = "5.7.2" + + create_cloudwatch_log_metric_filter = module.this.enabled + + name = local.filter_name + log_group_name = var.log_group_name + pattern = var.pattern + + metric_transformation_name = var.metric_transformation_name + metric_transformation_namespace = local.metric_namespace +} + +check "log_group_must_exist" { + assert { + condition = var.log_group_name != "" + error_message = "log_group_name is required and must not be empty." + } +} diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/outputs.tf b/infrastructure/modules/cloudwatch-log-metric-filter/outputs.tf new file mode 100644 index 00000000..74d547c2 --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/outputs.tf @@ -0,0 +1,14 @@ +output "cloudwatch_log_metric_filter_id" { + description = "ID of the CloudWatch log metric filter." + value = module.this.enabled ? module.log_metric_filter.cloudwatch_log_metric_filter_id : null +} + +output "metric_name" { + description = "Name of the metric emitted by this filter (for downstream alarm reference)." + value = module.this.enabled ? var.metric_transformation_name : null +} + +output "metric_namespace" { + description = "CloudWatch namespace where the metric is emitted." + value = module.this.enabled ? local.metric_namespace : null +} diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/variables.tf b/infrastructure/modules/cloudwatch-log-metric-filter/variables.tf new file mode 100644 index 00000000..a35f95ce --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/variables.tf @@ -0,0 +1,37 @@ +################################################################ +# CloudWatch Log Metric Filter submodule inputs. +# +# Naming and tagging come from context.tf via `module.this`. +################################################################ + +variable "log_group_name" { + type = string + description = "Name of the CloudWatch log group to monitor. Required." + + validation { + condition = length(var.log_group_name) > 0 + error_message = "log_group_name must not be empty." + } +} + +variable "pattern" { + type = string + default = "" + description = "Log pattern to filter on (e.g., 'ERROR', '[ERROR]', etc). Empty string matches all events." +} + +variable "metric_transformation_name" { + type = string + description = "Name of the metric to emit (e.g., 'ErrorCount'). Will be prefixed with log group name." + + validation { + condition = length(var.metric_transformation_name) > 0 + error_message = "metric_transformation_name must not be empty." + } +} + +variable "metric_transformation_namespace" { + type = string + description = "CloudWatch namespace for the metric (e.g., 'BCSS/Application'). Defaults to log group name if empty." + default = "" +} diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/versions.tf b/infrastructure/modules/cloudwatch-log-metric-filter/versions.tf new file mode 100644 index 00000000..cb30fe5c --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" + } + } +} diff --git a/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl b/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl new file mode 100644 index 00000000..4657683f --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.52.0" + constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" + hashes = [ + "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", + "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", + "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", + "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", + "zh:2d5562ed0e7cb0892fb537c7989d25fdde1d0ac1f3a768ba15fa087985f2a0f5", + "zh:40d64f668961a172355c3d11d258e19172ffafb421c40d89266b129ed8f92a5d", + "zh:497792bccc33001247473bc32a148c067982cb3d245b8f3610afb920635d2235", + "zh:8011f9167082af74ed9257582a83d54e889388656597721b2691797f1dd6fb58", + "zh:8b10d1bbca51b0da1e1be0967ce75b039f2d5d86f2ca7339994a92d35cdf47a8", + "zh:9549647dbf7c913512c26fed6badd7bf24a29a36c2bd308536849303a85427da", + "zh:97c4b726cdbe48166f4b9e6a1230312a7933dc19dd139d941ca6aca86706eb33", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a484bc8166278b546bfe53698fa9e0a919dffe3bfda3c8bf8a0fc6811b5b9e66", + "zh:aa96e76bd9f93e5395a553fccec485554a74acae46b3834b1296374eba4f010f", + "zh:cf290ee3d0dfe91b596445f860440d9f18826f7395ff785f83f70d36cedadd1c", + "zh:d56b6a79207663673f66d9ed378d705c1bd1b4d117eeb5e2be3938cf1b75b7be", + "zh:febef068317f8e49bd438ccdf2f54345fc3bc4b80a2699d9ab3c449d7e521d8e", + ] +} diff --git a/infrastructure/modules/cloudwatch-logs/README.md b/infrastructure/modules/cloudwatch-logs/README.md new file mode 100644 index 00000000..fe1d9385 --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/README.md @@ -0,0 +1,161 @@ +# CloudWatch Logs + +NHS Screening wrapper around the [terraform-aws-modules/CloudWatch/aws](https://registry.terraform.io/modules/terraform-aws-modules/cloudwatch/aws) `log-group` and `log-stream` submodules that enforces screening platform baseline controls. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Naming | Log group and stream names are derived from `module.this.id` via context | +| Tagging | All NHS-required tags applied via `module.this.tags` | +| Retention | Configurable retention in days; defaults to 7 days | +| Encryption | Optional KMS key support; AWS-managed by default | +| Creation gate | Both log group and stream gated via `module.this.enabled` | + +## Usage + +### Log group only + +```hcl +module "app_logs" { + source = "../../modules/cloudwatch-logs" + + context = module.this.context + stack = "app" + name = "ecs" + + create_log_group = true + create_log_stream = false + retention_in_days = 30 +} +``` + +### Log group + stream + +```hcl +module "lambda_logs" { + source = "../../modules/cloudwatch-logs" + + context = module.this.context + stack = "workers" + name = "background-jobs" + + create_log_group = true + create_log_stream = true + retention_in_days = 90 + kms_key_id = module.kms.key_arn +} + +# Use outputs for ECS/Lambda log routing +output "ecs_log_group" { + value = module.lambda_logs.cloudwatch_log_group_name +} +``` + +### With metric filter (reference log group) + +```hcl +module "logs" { + source = "../../modules/cloudwatch-logs" + + context = module.this.context + create_log_group = true + create_log_stream = false +} + +module "error_filter" { + source = "../../modules/cloudwatch-log-metric-filter" + + context = module.this.context + + log_group_name = module.logs.cloudwatch_log_group_name + pattern = "ERROR" + metric_transformation_name = "ErrorCount" + metric_transformation_namespace = "BCSS/Application" +} +``` + +## Conventions + +- Log group and stream names are derived from `module.this.id` (e.g., `service-environment-stack-name`). +- `create_log_stream = true` requires `create_log_group = true`; the check block enforces this. +- Log group retention defaults to 7 days; adjust via `retention_in_days`. +- When both are created, the stream is automatically associated with the group. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [log\_group](#module\_log\_group) | terraform-aws-modules/cloudwatch/aws//modules/log-group | 5.7.2 | +| [log\_stream](#module\_log\_stream) | terraform-aws-modules/cloudwatch/aws//modules/log-stream | 5.7.2 | +| [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 | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | 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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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\_log\_group](#input\_create\_log\_group) | Whether to create the CloudWatch log group. | `bool` | `true` | no | +| [create\_log\_stream](#input\_create\_log\_stream) | Whether to create a CloudWatch log stream. Requires create\_log\_group = true. | `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\_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 | +| [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 | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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\_id](#input\_kms\_key\_id) | ARN of KMS key for log group encryption. When null, uses AWS-managed encryption. | `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 | +| [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 | +| [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 | +| [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 | +| [retention\_in\_days](#input\_retention\_in\_days) | CloudWatch log group retention in days. Valid values: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653. | `number` | `7` | 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 | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [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 this module path. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | 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 CloudWatch log group, if created. | +| [cloudwatch\_log\_group\_name](#output\_cloudwatch\_log\_group\_name) | Name of the CloudWatch log group, if created. | +| [cloudwatch\_log\_stream\_arn](#output\_cloudwatch\_log\_stream\_arn) | ARN of the CloudWatch log stream, if created. | +| [cloudwatch\_log\_stream\_name](#output\_cloudwatch\_log\_stream\_name) | Name of the CloudWatch log stream, if created. | + + + diff --git a/infrastructure/modules/cloudwatch-logs/context.tf b/infrastructure/modules/cloudwatch-logs/context.tf new file mode 100644 index 00000000..62befcb0 --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/context.tf @@ -0,0 +1,376 @@ +# 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" { + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/cloudwatch-logs/locals.tf b/infrastructure/modules/cloudwatch-logs/locals.tf new file mode 100644 index 00000000..c8c0db4c --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/locals.tf @@ -0,0 +1,4 @@ +locals { + log_group_name = module.this.id + log_stream_name = format("%s-stream", module.this.id) +} diff --git a/infrastructure/modules/cloudwatch-logs/main.tf b/infrastructure/modules/cloudwatch-logs/main.tf new file mode 100644 index 00000000..38b6d8ea --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/main.tf @@ -0,0 +1,43 @@ +################################################################ +# CloudWatch Logs +# +# Thin NHS wrapper around the community CloudWatch log-group and +# log-stream submodules that enforces screening platform baseline +# controls: +# +# * Naming: derived from context labels via module.this.id +# * Tagging: all NHS-required tags applied automatically +# * Retention: configurable; defaults to 7 days +# * Encryption: optional KMS key support +# * Enabled flag: create = module.this.enabled +################################################################ + +module "log_group" { + source = "terraform-aws-modules/cloudwatch/aws//modules/log-group" + version = "5.7.2" + + create = module.this.enabled && var.create_log_group + + name = local.log_group_name + retention_in_days = var.retention_in_days + kms_key_id = var.kms_key_id + + tags = module.this.tags +} + +module "log_stream" { + source = "terraform-aws-modules/cloudwatch/aws//modules/log-stream" + version = "5.7.2" + + create = module.this.enabled && var.create_log_stream && var.create_log_group + + name = local.log_stream_name + log_group_name = module.log_group.cloudwatch_log_group_name +} + +check "log_stream_requires_log_group" { + assert { + condition = !var.create_log_stream || var.create_log_group + error_message = "create_log_stream requires create_log_group = true" + } +} diff --git a/infrastructure/modules/cloudwatch-logs/outputs.tf b/infrastructure/modules/cloudwatch-logs/outputs.tf new file mode 100644 index 00000000..484169cd --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/outputs.tf @@ -0,0 +1,19 @@ +output "cloudwatch_log_group_name" { + description = "Name of the CloudWatch log group, if created." + value = var.create_log_group ? module.log_group.cloudwatch_log_group_name : null +} + +output "cloudwatch_log_group_arn" { + description = "ARN of the CloudWatch log group, if created." + value = var.create_log_group ? module.log_group.cloudwatch_log_group_arn : null +} + +output "cloudwatch_log_stream_name" { + description = "Name of the CloudWatch log stream, if created." + value = var.create_log_stream ? module.log_stream.cloudwatch_log_stream_name : null +} + +output "cloudwatch_log_stream_arn" { + description = "ARN of the CloudWatch log stream, if created." + value = var.create_log_stream ? module.log_stream.cloudwatch_log_stream_arn : null +} diff --git a/infrastructure/modules/cloudwatch-logs/variables.tf b/infrastructure/modules/cloudwatch-logs/variables.tf new file mode 100644 index 00000000..0bbd103e --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/variables.tf @@ -0,0 +1,35 @@ +################################################################ +# CloudWatch Logs submodule inputs. +# +# Naming, tagging and the master `enabled` switch come from +# context.tf via `module.this`. +################################################################ + +variable "create_log_group" { + type = bool + default = true + description = "Whether to create the CloudWatch log group." +} + +variable "create_log_stream" { + type = bool + default = false + description = "Whether to create a CloudWatch log stream. Requires create_log_group = true." +} + +variable "retention_in_days" { + type = number + default = 7 + description = "CloudWatch log group retention in days. Valid values: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653." + + validation { + condition = contains([1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653], var.retention_in_days) + error_message = "retention_in_days must be one of: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1827, 3653." + } +} + +variable "kms_key_id" { + type = string + default = null + description = "ARN of KMS key for log group encryption. When null, uses AWS-managed encryption." +} diff --git a/infrastructure/modules/cloudwatch-logs/versions.tf b/infrastructure/modules/cloudwatch-logs/versions.tf new file mode 100644 index 00000000..cb30fe5c --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" + } + } +} diff --git a/infrastructure/modules/cloudwatch-metric-alarm/.terraform.lock.hcl b/infrastructure/modules/cloudwatch-metric-alarm/.terraform.lock.hcl new file mode 100644 index 00000000..4657683f --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/.terraform.lock.hcl @@ -0,0 +1,26 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.52.0" + constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" + hashes = [ + "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", + "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", + "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", + "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", + "zh:2d5562ed0e7cb0892fb537c7989d25fdde1d0ac1f3a768ba15fa087985f2a0f5", + "zh:40d64f668961a172355c3d11d258e19172ffafb421c40d89266b129ed8f92a5d", + "zh:497792bccc33001247473bc32a148c067982cb3d245b8f3610afb920635d2235", + "zh:8011f9167082af74ed9257582a83d54e889388656597721b2691797f1dd6fb58", + "zh:8b10d1bbca51b0da1e1be0967ce75b039f2d5d86f2ca7339994a92d35cdf47a8", + "zh:9549647dbf7c913512c26fed6badd7bf24a29a36c2bd308536849303a85427da", + "zh:97c4b726cdbe48166f4b9e6a1230312a7933dc19dd139d941ca6aca86706eb33", + "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", + "zh:a484bc8166278b546bfe53698fa9e0a919dffe3bfda3c8bf8a0fc6811b5b9e66", + "zh:aa96e76bd9f93e5395a553fccec485554a74acae46b3834b1296374eba4f010f", + "zh:cf290ee3d0dfe91b596445f860440d9f18826f7395ff785f83f70d36cedadd1c", + "zh:d56b6a79207663673f66d9ed378d705c1bd1b4d117eeb5e2be3938cf1b75b7be", + "zh:febef068317f8e49bd438ccdf2f54345fc3bc4b80a2699d9ab3c449d7e521d8e", + ] +} diff --git a/infrastructure/modules/cloudwatch-metric-alarm/README.md b/infrastructure/modules/cloudwatch-metric-alarm/README.md new file mode 100644 index 00000000..5d05aeef --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/README.md @@ -0,0 +1,184 @@ +# CloudWatch Metric Alarms + +NHS Screening wrapper around the [terraform-aws-modules/CloudWatch/aws](https://registry.terraform.io/modules/terraform-aws-modules/cloudwatch/aws) `metric-alarm` and `metric-alarms-by-multiple-dimensions` submodules that enforces screening platform baseline controls. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Naming | Alarm names derived from context.id + alarm suffix | +| Period | Hardcoded to 60 seconds for consistency | +| Statistic | Defaults to Sum; configurable per alarm | +| SNS actions | Optional list of topic ARNs for notifications | +| Missing data | Defaults to `notBreaching` (safe default) | +| Tagging | All NHS-required tags applied via `module.this.tags` | +| Creation gate | Both alarms gated via `module.this.enabled` | + +## Usage + +### Single metric alarm + +```hcl +module "app_errors_alarm" { + source = "../../modules/cloudwatch-metric-alarm" + + context = module.this.context + stack = "app" + name = "errors" + + metric_alarm = { + metric_name = "ErrorCount" + namespace = "BCSS/Application" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 2 + threshold = 10 + statistic = "Sum" + } + + alarm_actions = [module.sns.topic_arn] +} +``` + +### Multi-dimension alarm + +```hcl +module "ecs_task_alarms" { + source = "../../modules/cloudwatch-metric-alarm" + + context = module.this.context + + metric_alarms_by_multiple_dimensions = { + metric_name = "CPUUtilization" + namespace = "AWS/ECS" + comparison_operator = "GreaterThanThreshold" + evaluation_periods = 3 + threshold = 80 + dimensions = { + ServiceName = "my-service" + ClusterName = "my-cluster" + } + } + + alarm_actions = [module.sns.topic_arn] + insufficient_data_actions = [module.sns.dead_letter_arn] +} +``` + +### Referencing log metric filter output + +```hcl +module "error_filter" { + source = "../../modules/cloudwatch-log-metric-filter" + + context = module.this.context + + log_group_name = module.logs.cloudwatch_log_group_name + pattern = "ERROR" + metric_transformation_name = "ApplicationErrors" + metric_transformation_namespace = "BCSS/Application" +} + +module "error_alarm" { + source = "../../modules/cloudwatch-metric-alarm" + + context = module.this.context + + metric_alarm = { + metric_name = module.error_filter.metric_name + namespace = module.error_filter.metric_namespace + comparison_operator = "GreaterThanOrEqualToThreshold" + evaluation_periods = 1 + threshold = 1 + } + + alarm_actions = [module.sns.topic_arn] +} +``` + +## Conventions + +- Alarm names use format `{context.id}-alarm` (single) or `{context.id}-multi-alarm` (multi-dimension). +- Period is always 60 seconds for simplicity. +- Missing data defaults to `notBreaching` (safe for production). +- SNS actions (alarm, ok, insufficient data) are all optional. +- At least one of `metric_alarm` or `metric_alarms_by_multiple_dimensions` must be configured (enforced by check block). + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [metric\_alarm](#module\_metric\_alarm) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarm | 5.7.2 | +| [metric\_alarms\_by\_multiple\_dimensions](#module\_metric\_alarms\_by\_multiple\_dimensions) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions | 5.7.2 | +| [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 | +| [alarm\_actions](#input\_alarm\_actions) | List of SNS topic ARNs to notify when alarm fires (optional). | `list(string)` | `[]` | no | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | 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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | +| [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 | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | +| [insufficient\_data\_actions](#input\_insufficient\_data\_actions) | List of SNS topic ARNs to notify when alarm has insufficient data (optional). | `list(string)` | `[]` | 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 | +| [metric\_alarm](#input\_metric\_alarm) | Configuration for a single metric alarm. Set to null to skip creation. |
object({
metric_name = string
namespace = string
comparison_operator = string
evaluation_periods = number
threshold = number
statistic = optional(string, "Sum")
period = optional(number, 60)
actions_enabled = optional(bool, true)
})
| `null` | no | +| [metric\_alarms\_by\_multiple\_dimensions](#input\_metric\_alarms\_by\_multiple\_dimensions) | Configuration for metric alarms by multiple dimensions (creates one alarm per dimension combo). Set to null to skip creation. |
object({
metric_name = string
namespace = string
comparison_operator = string
evaluation_periods = number
threshold = number
statistic = optional(string, "Sum")
period = optional(number, 60)
actions_enabled = optional(bool, true)
dimensions = map(string)
})
| `null` | 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 | +| [ok\_actions](#input\_ok\_actions) | List of SNS topic ARNs to notify when alarm recovers (optional). | `list(string)` | `[]` | 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 | +| [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 | +| [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 | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [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 this module path. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [treat\_missing\_data](#input\_treat\_missing\_data) | How to handle missing data points: 'notBreaching', 'breaching', 'missing', 'ignoreMetricTime'. | `string` | `"notBreaching"` | 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\_metric\_alarm\_arn](#output\_cloudwatch\_metric\_alarm\_arn) | Alarm ARN from single metric alarm (if created). | +| [cloudwatch\_metric\_alarm\_id](#output\_cloudwatch\_metric\_alarm\_id) | Alarm ID from single metric alarm (if created). | +| [cloudwatch\_metric\_alarms\_by\_multiple\_dimensions\_arns](#output\_cloudwatch\_metric\_alarms\_by\_multiple\_dimensions\_arns) | Map of alarm ARNs keyed by dimension combination (if created). | +| [cloudwatch\_metric\_alarms\_by\_multiple\_dimensions\_ids](#output\_cloudwatch\_metric\_alarms\_by\_multiple\_dimensions\_ids) | Map of alarm IDs keyed by dimension combination (if created). | + + + diff --git a/infrastructure/modules/cloudwatch-metric-alarm/context.tf b/infrastructure/modules/cloudwatch-metric-alarm/context.tf new file mode 100644 index 00000000..62befcb0 --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/context.tf @@ -0,0 +1,376 @@ +# 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" { + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/cloudwatch-metric-alarm/locals.tf b/infrastructure/modules/cloudwatch-metric-alarm/locals.tf new file mode 100644 index 00000000..245bf71e --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/locals.tf @@ -0,0 +1,4 @@ +locals { + single_alarm_name = format("%s-alarm", module.this.id) + multi_dimension_alarm_name = format("%s-multi-alarm", module.this.id) +} diff --git a/infrastructure/modules/cloudwatch-metric-alarm/main.tf b/infrastructure/modules/cloudwatch-metric-alarm/main.tf new file mode 100644 index 00000000..20d43dee --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/main.tf @@ -0,0 +1,71 @@ +################################################################ +# CloudWatch Metric Alarms +# +# Thin NHS wrapper around the community CloudWatch metric-alarm and +# metric-alarms-by-multiple-dimensions submodules that enforces +# screening platform baseline controls: +# +# * Naming: derived from context.id + alarm suffix +# * Period: hardcoded to 60 seconds (enforced) +# * Statistic: defaults to Sum (configurable per-alarm) +# * Actions: SNS topic ARNs optional for notifications +# * Enabled flag: create = module.this.enabled +################################################################ + +module "metric_alarm" { + source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm" + version = "5.7.2" + + create_metric_alarm = module.this.enabled && var.metric_alarm != null + + alarm_name = local.single_alarm_name + comparison_operator = var.metric_alarm.comparison_operator + evaluation_periods = var.metric_alarm.evaluation_periods + threshold = var.metric_alarm.threshold + statistic = var.metric_alarm.statistic + period = var.metric_alarm.period + actions_enabled = var.metric_alarm.actions_enabled + + metric_name = var.metric_alarm.metric_name + namespace = var.metric_alarm.namespace + + alarm_actions = var.alarm_actions + ok_actions = var.ok_actions + insufficient_data_actions = var.insufficient_data_actions + treat_missing_data = var.treat_missing_data + + tags = module.this.tags +} + +module "metric_alarms_by_multiple_dimensions" { + source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions" + version = "5.7.2" + + create_metric_alarm = module.this.enabled && var.metric_alarms_by_multiple_dimensions != null + + alarm_name = local.multi_dimension_alarm_name + comparison_operator = var.metric_alarms_by_multiple_dimensions.comparison_operator + evaluation_periods = var.metric_alarms_by_multiple_dimensions.evaluation_periods + threshold = var.metric_alarms_by_multiple_dimensions.threshold + statistic = var.metric_alarms_by_multiple_dimensions.statistic + period = var.metric_alarms_by_multiple_dimensions.period + actions_enabled = var.metric_alarms_by_multiple_dimensions.actions_enabled + dimensions = var.metric_alarms_by_multiple_dimensions.dimensions + + metric_name = var.metric_alarms_by_multiple_dimensions.metric_name + namespace = var.metric_alarms_by_multiple_dimensions.namespace + + alarm_actions = var.alarm_actions + ok_actions = var.ok_actions + insufficient_data_actions = var.insufficient_data_actions + treat_missing_data = var.treat_missing_data + + tags = module.this.tags +} + +check "at_least_one_alarm_configured" { + assert { + condition = var.metric_alarm != null || var.metric_alarms_by_multiple_dimensions != null + error_message = "At least one of metric_alarm or metric_alarms_by_multiple_dimensions must be configured." + } +} diff --git a/infrastructure/modules/cloudwatch-metric-alarm/outputs.tf b/infrastructure/modules/cloudwatch-metric-alarm/outputs.tf new file mode 100644 index 00000000..04fb1ed6 --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/outputs.tf @@ -0,0 +1,19 @@ +output "cloudwatch_metric_alarm_id" { + description = "Alarm ID from single metric alarm (if created)." + value = var.metric_alarm != null ? module.metric_alarm.cloudwatch_metric_alarm_id : null +} + +output "cloudwatch_metric_alarm_arn" { + description = "Alarm ARN from single metric alarm (if created)." + value = var.metric_alarm != null ? module.metric_alarm.cloudwatch_metric_alarm_arn : null +} + +output "cloudwatch_metric_alarms_by_multiple_dimensions_ids" { + description = "Map of alarm IDs keyed by dimension combination (if created)." + value = var.metric_alarms_by_multiple_dimensions != null ? module.metric_alarms_by_multiple_dimensions.cloudwatch_metric_alarm_ids : {} +} + +output "cloudwatch_metric_alarms_by_multiple_dimensions_arns" { + description = "Map of alarm ARNs keyed by dimension combination (if created)." + value = var.metric_alarms_by_multiple_dimensions != null ? module.metric_alarms_by_multiple_dimensions.cloudwatch_metric_alarm_arns : {} +} diff --git a/infrastructure/modules/cloudwatch-metric-alarm/variables.tf b/infrastructure/modules/cloudwatch-metric-alarm/variables.tf new file mode 100644 index 00000000..fbd76c3c --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/variables.tf @@ -0,0 +1,65 @@ +################################################################ +# CloudWatch Metric Alarm submodule inputs. +# +# Naming and tagging come from context.tf via `module.this`. +################################################################ + +variable "metric_alarm" { + type = object({ + metric_name = string + namespace = string + comparison_operator = string + evaluation_periods = number + threshold = number + statistic = optional(string, "Sum") + period = optional(number, 60) + actions_enabled = optional(bool, true) + }) + default = null + description = "Configuration for a single metric alarm. Set to null to skip creation." +} + +variable "metric_alarms_by_multiple_dimensions" { + type = object({ + metric_name = string + namespace = string + comparison_operator = string + evaluation_periods = number + threshold = number + statistic = optional(string, "Sum") + period = optional(number, 60) + actions_enabled = optional(bool, true) + dimensions = map(string) + }) + default = null + description = "Configuration for metric alarms by multiple dimensions (creates one alarm per dimension combo). Set to null to skip creation." +} + +variable "alarm_actions" { + type = list(string) + default = [] + description = "List of SNS topic ARNs to notify when alarm fires (optional)." +} + +variable "ok_actions" { + type = list(string) + default = [] + description = "List of SNS topic ARNs to notify when alarm recovers (optional)." +} + +variable "insufficient_data_actions" { + type = list(string) + default = [] + description = "List of SNS topic ARNs to notify when alarm has insufficient data (optional)." +} + +variable "treat_missing_data" { + type = string + default = "notBreaching" + description = "How to handle missing data points: 'notBreaching', 'breaching', 'missing', 'ignoreMetricTime'." + + validation { + condition = contains(["notBreaching", "breaching", "missing", "ignoreMetricTime"], var.treat_missing_data) + error_message = "treat_missing_data must be one of: notBreaching, breaching, missing, ignoreMetricTime." + } +} diff --git a/infrastructure/modules/cloudwatch-metric-alarm/versions.tf b/infrastructure/modules/cloudwatch-metric-alarm/versions.tf new file mode 100644 index 00000000..cb30fe5c --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" + } + } +} diff --git a/scripts/config/generate-available-modules.yaml b/scripts/config/generate-available-modules.yaml index 5cd9f165..b589354a 100644 --- a/scripts/config/generate-available-modules.yaml +++ b/scripts/config/generate-available-modules.yaml @@ -33,6 +33,18 @@ cloudwatch: description: "CloudWatch log groups, streams, metric filters, and alarms" wraps: "terraform-aws-modules/cloudwatch/aws" +cloudwatch-log-metric-filter: + description: "CloudWatch log metric filters (emits metrics from log patterns)" + wraps: "terraform-aws-modules/cloudwatch/aws" + +cloudwatch-logs: + description: "CloudWatch log groups and streams" + wraps: "terraform-aws-modules/cloudwatch/aws" + +cloudwatch-metric-alarm: + description: "CloudWatch metric alarms (single and multi-dimension)" + wraps: "terraform-aws-modules/cloudwatch/aws" + cognito: description: "Cognito user and identity pools" wraps: "—" From 215ecc22005cbc222dc285a27769c470d97f3028 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 1 Jul 2026 14:40:06 +0100 Subject: [PATCH 069/118] Removed custom_name variable --- infrastructure/modules/rds/README.md | 4 ++-- infrastructure/modules/rds/locals.tf | 2 +- infrastructure/modules/rds/variables.tf | 8 +------- 3 files changed, 4 insertions(+), 10 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 0c3aed31..7da08e5e 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -62,7 +62,7 @@ module "oracle_rds" { # identity (optional) # If omitted, the module derives a name from context labels. - custom_name = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" + identifier = "${var.name_prefix}-oracle-${var.environment}-${terraform.workspace}" # engine engine = "oracle-ee" @@ -251,7 +251,7 @@ The master password arn is exposed via the `master_user_secret_arn` output. ## Conventions - Naming and tagging come from shared `context.tf` via `module.this`. -- Identifier resolution order is `custom_name`, then `identifier`, then `module.this.id`. +- Identifier resolution order is `identifier`, then `module.this.id`. If neither is set, the name is derived from context labels. - Security groups are intentionally caller-managed. This module associates IDs passed via `vpc_security_group_ids`. - Resource creation is gated by `module.this.enabled`. - Snapshot tagging is always enabled via `copy_tags_to_snapshot = true`. diff --git a/infrastructure/modules/rds/locals.tf b/infrastructure/modules/rds/locals.tf index 0f350376..27c39695 100644 --- a/infrastructure/modules/rds/locals.tf +++ b/infrastructure/modules/rds/locals.tf @@ -1,4 +1,4 @@ locals { # Prefer explicit caller names when provided, otherwise derive from context labels. - rds_identifier = coalesce(var.custom_name, var.identifier, module.this.id) + rds_identifier = coalesce(var.identifier, module.this.id) } diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index 83bee1b3..feb24f90 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -3,13 +3,7 @@ # ---------------------------------------------------------------------------- variable "identifier" { - description = "Explicit name for the RDS instance. If null, this module derives the name from context labels." - type = string - default = null -} - -variable "custom_name" { - description = "Optional override name for the RDS instance. Takes precedence over identifier when set." + description = "Explicit identifier for the RDS instance. When set, overrides the name derived from context labels. Use this when migrating from an existing instance that already has a specific identifier, or when the context-derived name would be too long for RDS." type = string default = null } From 59872ad06353f0c6a8eeb12d8a851a8f75bb76f9 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 1 Jul 2026 14:59:21 +0100 Subject: [PATCH 070/118] Added Cross-variable validation for password_wo --- infrastructure/modules/rds/main.tf | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 32c8cfce..3d8e31b2 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -10,6 +10,21 @@ # - auto_minor_version_upgrade = false (teams keep instances in sync with prod) # - create_db_subnet_group = true (subnet group always managed by this module) # ---------------------------------------------------------------------------- + +# Cross-variable validation: password_wo is required unless the password is +# managed by RDS (manage_master_user_password = true) or the instance is being +# restored from a snapshot (snapshot_identifier is set). +resource "terraform_data" "validate_password" { + count = module.this.enabled ? 1 : 0 + + lifecycle { + precondition { + condition = var.manage_master_user_password || var.snapshot_identifier != null || var.password_wo != null + error_message = "password_wo must be provided when manage_master_user_password is false and snapshot_identifier is not set." + } + } +} + module "rds" { source = "terraform-aws-modules/rds/aws" version = "7.2.0" From d378f6e438c8bc11b62d7a6766fbebd27b1e52c8 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 1 Jul 2026 15:08:21 +0100 Subject: [PATCH 071/118] Added Cross-variable validation for character_set_name --- infrastructure/modules/rds/main.tf | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 3d8e31b2..807dd5e0 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -11,10 +11,9 @@ # - create_db_subnet_group = true (subnet group always managed by this module) # ---------------------------------------------------------------------------- -# Cross-variable validation: password_wo is required unless the password is -# managed by RDS (manage_master_user_password = true) or the instance is being -# restored from a snapshot (snapshot_identifier is set). -resource "terraform_data" "validate_password" { +# Cross-variable validation — enforces rules that cannot be expressed inside +# individual variable validation blocks because they reference multiple variables. +resource "terraform_data" "validate_inputs" { count = module.this.enabled ? 1 : 0 lifecycle { @@ -22,6 +21,11 @@ resource "terraform_data" "validate_password" { condition = var.manage_master_user_password || var.snapshot_identifier != null || var.password_wo != null error_message = "password_wo must be provided when manage_master_user_password is false and snapshot_identifier is not set." } + + precondition { + condition = var.snapshot_identifier == null || var.character_set_name == null + error_message = "character_set_name must be null when restoring from a snapshot (snapshot_identifier is set)." + } } } From f073d34341076c14d15c130afe1e8f7e98674b57 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 1 Jul 2026 15:31:04 +0100 Subject: [PATCH 072/118] Added validation for monitoring_interval --- infrastructure/modules/rds/variables.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index feb24f90..fff36af0 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -219,6 +219,11 @@ variable "monitoring_interval" { description = "Interval in seconds between Enhanced Monitoring data points. Valid values: 0, 1, 5, 10, 15, 30, 60. Set to 0 to disable" type = number default = 5 + + validation { + condition = contains([0, 1, 5, 10, 15, 30, 60], var.monitoring_interval) + error_message = "monitoring_interval must be one of: 0, 1, 5, 10, 15, 30, 60." + } } variable "performance_insights_enabled" { From 3e48aa6a519acf5877283fbe5e6196f13ee901a5 Mon Sep 17 00:00:00 2001 From: Pira-nhs Date: Wed, 1 Jul 2026 15:38:28 +0100 Subject: [PATCH 073/118] feat(cloudwatch): update .terraform.lock.hcl with additional provider hashes --- .../modules/cloudwatch-log-metric-filter/.terraform.lock.hcl | 4 ++++ infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl | 4 ++++ .../modules/cloudwatch-metric-alarm/.terraform.lock.hcl | 4 ++++ 3 files changed, 12 insertions(+) diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/.terraform.lock.hcl b/infrastructure/modules/cloudwatch-log-metric-filter/.terraform.lock.hcl index 4657683f..82c67711 100644 --- a/infrastructure/modules/cloudwatch-log-metric-filter/.terraform.lock.hcl +++ b/infrastructure/modules/cloudwatch-log-metric-filter/.terraform.lock.hcl @@ -5,6 +5,10 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.52.0" constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" hashes = [ + "h1:8m5zm0JaUac+YikXZoZDTV7R0iYL+q3IvTL4Ac8GfDA=", + "h1:Dg9X3Gde96OCT3TovLKAKumnR64VqIIQ37m/9NRJ00Y=", + "h1:F1r7mTJ289EBvU7Z7XAvWMHUt3VJ3zSFH8SUjLPFiqM=", + "h1:iXIFHX6zIvG0hoEi1fImeuHG9V4JOop6O6L6f+MOL2c=", "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", diff --git a/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl b/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl index 4657683f..82c67711 100644 --- a/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl +++ b/infrastructure/modules/cloudwatch-logs/.terraform.lock.hcl @@ -5,6 +5,10 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.52.0" constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" hashes = [ + "h1:8m5zm0JaUac+YikXZoZDTV7R0iYL+q3IvTL4Ac8GfDA=", + "h1:Dg9X3Gde96OCT3TovLKAKumnR64VqIIQ37m/9NRJ00Y=", + "h1:F1r7mTJ289EBvU7Z7XAvWMHUt3VJ3zSFH8SUjLPFiqM=", + "h1:iXIFHX6zIvG0hoEi1fImeuHG9V4JOop6O6L6f+MOL2c=", "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", diff --git a/infrastructure/modules/cloudwatch-metric-alarm/.terraform.lock.hcl b/infrastructure/modules/cloudwatch-metric-alarm/.terraform.lock.hcl index 4657683f..82c67711 100644 --- a/infrastructure/modules/cloudwatch-metric-alarm/.terraform.lock.hcl +++ b/infrastructure/modules/cloudwatch-metric-alarm/.terraform.lock.hcl @@ -5,6 +5,10 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.52.0" constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" hashes = [ + "h1:8m5zm0JaUac+YikXZoZDTV7R0iYL+q3IvTL4Ac8GfDA=", + "h1:Dg9X3Gde96OCT3TovLKAKumnR64VqIIQ37m/9NRJ00Y=", + "h1:F1r7mTJ289EBvU7Z7XAvWMHUt3VJ3zSFH8SUjLPFiqM=", + "h1:iXIFHX6zIvG0hoEi1fImeuHG9V4JOop6O6L6f+MOL2c=", "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", From 0b740b277b0df4a96bb5d145e0271c2a80a4bd64 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 1 Jul 2026 16:16:54 +0100 Subject: [PATCH 074/118] Added validation for performance_insights_retention_period --- infrastructure/modules/rds/variables.tf | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index fff36af0..d95beaf9 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -236,6 +236,15 @@ variable "performance_insights_retention_period" { description = "Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31" type = number default = 7 + + validation { + condition = ( + var.performance_insights_retention_period == 7 || + var.performance_insights_retention_period == 731 || + (var.performance_insights_retention_period % 31 == 0 && var.performance_insights_retention_period >= 31) + ) + error_message = "performance_insights_retention_period must be 7, 731, or a multiple of 31 (e.g. 31, 62, 93)." + } } variable "performance_insights_kms_key_id" { From 6aed6c54c8846e24f66742f6a12839f65f067ff4 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Wed, 1 Jul 2026 16:26:03 +0100 Subject: [PATCH 075/118] Added precondition for security group ids --- infrastructure/modules/rds/README.md | 1 + infrastructure/modules/rds/main.tf | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 7da08e5e..15bf4786 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -13,6 +13,7 @@ The module provisions an RDS DB instance together with its subnet group, paramet |`copy_tags_to_snapshot`|`true`|Snapshots must carry the same tags as the instance| |`auto_minor_version_upgrade`|`false`|Teams keep instances in sync with the production engine version| |`create_db_subnet_group`|`true`|subnet group is always managed by this module| +|`vpc_security_group_ids`|Non-empty list required|RDS must not rely on the default VPC security group; callers must always supply at least one explicit security group| |Creation gate|`module.this.enabled`|Prevents all managed RDS resources when disabled| ## Usage diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 807dd5e0..2a9bfb37 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -26,6 +26,11 @@ resource "terraform_data" "validate_inputs" { condition = var.snapshot_identifier == null || var.character_set_name == null error_message = "character_set_name must be null when restoring from a snapshot (snapshot_identifier is set)." } + + precondition { + condition = length(var.vpc_security_group_ids) > 0 + error_message = "vpc_security_group_ids must not be empty. Create a security group using the dedicated security group module and pass its ID here." + } } } From 026f9f5ca05f5b9d5835e6ac2dd27f3196343d92 Mon Sep 17 00:00:00 2001 From: Pira-nhs Date: Wed, 1 Jul 2026 16:35:08 +0100 Subject: [PATCH 076/118] remove singlular CW module --- .../modules/cloudwatch/.terraform.lock.hcl | 30 -- infrastructure/modules/cloudwatch/README.md | 165 -------- infrastructure/modules/cloudwatch/context.tf | 376 ------------------ infrastructure/modules/cloudwatch/locals.tf | 27 -- infrastructure/modules/cloudwatch/main.tf | 117 ------ infrastructure/modules/cloudwatch/outputs.tf | 44 -- .../modules/cloudwatch/variables.tf | 49 --- infrastructure/modules/cloudwatch/versions.tf | 10 - 8 files changed, 818 deletions(-) delete mode 100644 infrastructure/modules/cloudwatch/.terraform.lock.hcl delete mode 100644 infrastructure/modules/cloudwatch/README.md delete mode 100644 infrastructure/modules/cloudwatch/context.tf delete mode 100644 infrastructure/modules/cloudwatch/locals.tf delete mode 100644 infrastructure/modules/cloudwatch/main.tf delete mode 100644 infrastructure/modules/cloudwatch/outputs.tf delete mode 100644 infrastructure/modules/cloudwatch/variables.tf delete mode 100644 infrastructure/modules/cloudwatch/versions.tf diff --git a/infrastructure/modules/cloudwatch/.terraform.lock.hcl b/infrastructure/modules/cloudwatch/.terraform.lock.hcl deleted file mode 100644 index 82c67711..00000000 --- a/infrastructure/modules/cloudwatch/.terraform.lock.hcl +++ /dev/null @@ -1,30 +0,0 @@ -# This file is maintained automatically by "terraform init". -# Manual edits may be lost in future updates. - -provider "registry.terraform.io/hashicorp/aws" { - version = "6.52.0" - constraints = ">= 5.81.0, >= 6.14.0, >= 6.42.0" - hashes = [ - "h1:8m5zm0JaUac+YikXZoZDTV7R0iYL+q3IvTL4Ac8GfDA=", - "h1:Dg9X3Gde96OCT3TovLKAKumnR64VqIIQ37m/9NRJ00Y=", - "h1:F1r7mTJ289EBvU7Z7XAvWMHUt3VJ3zSFH8SUjLPFiqM=", - "h1:iXIFHX6zIvG0hoEi1fImeuHG9V4JOop6O6L6f+MOL2c=", - "h1:lpXqosKH8yAahK3SA1P5Pdy1ziXJcY+blUidY0q9yGk=", - "zh:1ab1d78f2336fed42b4e13fa0077a0be9d86a7899897cda5b9f1a60051ec2e93", - "zh:1df11f5f252030803939a1c778931dbdcee1b1070b38b98d9a8cbfc26c2aeb8e", - "zh:20ec9af03c0c1f2f8582a8805d43a4cd1a0a65082308e00f025d87605715a88f", - "zh:2d5562ed0e7cb0892fb537c7989d25fdde1d0ac1f3a768ba15fa087985f2a0f5", - "zh:40d64f668961a172355c3d11d258e19172ffafb421c40d89266b129ed8f92a5d", - "zh:497792bccc33001247473bc32a148c067982cb3d245b8f3610afb920635d2235", - "zh:8011f9167082af74ed9257582a83d54e889388656597721b2691797f1dd6fb58", - "zh:8b10d1bbca51b0da1e1be0967ce75b039f2d5d86f2ca7339994a92d35cdf47a8", - "zh:9549647dbf7c913512c26fed6badd7bf24a29a36c2bd308536849303a85427da", - "zh:97c4b726cdbe48166f4b9e6a1230312a7933dc19dd139d941ca6aca86706eb33", - "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:a484bc8166278b546bfe53698fa9e0a919dffe3bfda3c8bf8a0fc6811b5b9e66", - "zh:aa96e76bd9f93e5395a553fccec485554a74acae46b3834b1296374eba4f010f", - "zh:cf290ee3d0dfe91b596445f860440d9f18826f7395ff785f83f70d36cedadd1c", - "zh:d56b6a79207663673f66d9ed378d705c1bd1b4d117eeb5e2be3938cf1b75b7be", - "zh:febef068317f8e49bd438ccdf2f54345fc3bc4b80a2699d9ab3c449d7e521d8e", - ] -} diff --git a/infrastructure/modules/cloudwatch/README.md b/infrastructure/modules/cloudwatch/README.md deleted file mode 100644 index 89301990..00000000 --- a/infrastructure/modules/cloudwatch/README.md +++ /dev/null @@ -1,165 +0,0 @@ -# CloudWatch - -NHS Screening wrapper around selected submodules from the -[terraform-aws-modules CloudWatch module](https://registry.terraform.io/modules/terraform-aws-modules/cloudwatch/aws/latest) -that provides a single module entry point for common CloudWatch log and alarm building blocks. - -## Included submodules - -- `log-group` -- `log-stream` -- `log-metric-filter` -- `metric-alarm` -- `metric-alarms-by-multiple-dimensions` - -## What this module enforces - -| Control | How it is enforced | -| ------- | ------------------ | -| Single entry point | One shared wrapper exposes the requested CloudWatch submodules together | -| Creation gate | Each submodule is gated by `module.this.enabled` and a non-null config object | -| Naming | Names are derived from `module.this.id` | -| Tagging | Log groups and alarms that support tags receive `module.this.tags` | -| Minimal interface | Only the minimal required or functionally necessary configuration is exposed | - -## Usage - -### Complete example - -```hcl -module "cloudwatch" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/cloudwatch?ref=main" - - service = "bcss" - project = "shared-resources" - environment = "prod" - stack = "monitoring" - name = "application" - - log_group = {} - - log_stream = {} - - log_metric_filter = { - pattern = "ERROR" - metric_transformation_name = "ErrorCount" - metric_transformation_namespace = "BCSS/Application" - } - - metric_alarm = { - comparison_operator = "GreaterThanOrEqualToThreshold" - evaluation_periods = 1 - threshold = 10 - } - - metric_alarms_by_multiple_dimensions = { - comparison_operator = "GreaterThanOrEqualToThreshold" - evaluation_periods = 1 - threshold = 10 - dimensions = { - lambda1 = { - FunctionName = "function-one" - } - lambda2 = { - FunctionName = "function-two" - } - } - } -} -``` - -## Conventions - -- Set a submodule object to `null` to skip creating that submodule. -- `log_stream` and `log_metric_filter` depend on `log_group` being configured in the same module call. -- `metric_alarm` and `metric_alarms_by_multiple_dimensions` derive their metric name and namespace from `log_metric_filter`. -- `metric_alarm` uses fixed defaults of `period = "60"` and `statistic = "Sum"`. -- `metric_alarms_by_multiple_dimensions` uses fixed defaults of `period = "60"` and `statistic = "Sum"`. -- CloudWatch log streams and log metric filters do not support tags directly, so only the submodules that accept tags receive `module.this.tags`. - - - - -## Requirements - -| Name | Version | -| ---- | ------- | -| [terraform](#requirement\_terraform) | >= 1.13 | -| [aws](#requirement\_aws) | >= 6.42 | - -## Providers - -No providers. - -## Modules - -| Name | Source | Version | -| ---- | ------ | ------- | -| [log\_group](#module\_log\_group) | terraform-aws-modules/cloudwatch/aws//modules/log-group | 5.7.2 | -| [log\_metric\_filter](#module\_log\_metric\_filter) | terraform-aws-modules/cloudwatch/aws//modules/log-metric-filter | 5.7.2 | -| [log\_stream](#module\_log\_stream) | terraform-aws-modules/cloudwatch/aws//modules/log-stream | 5.7.2 | -| [metric\_alarm](#module\_metric\_alarm) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarm | 5.7.2 | -| [metric\_alarms\_by\_multiple\_dimensions](#module\_metric\_alarms\_by\_multiple\_dimensions) | terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions | 5.7.2 | -| [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 | -| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | 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 | -| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | -| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | -| [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 | -| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | -| [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\_group](#input\_log\_group) | Configuration for the CloudWatch log group submodule. Set to null to skip creating a log group. | `object({})` | `null` | no | -| [log\_metric\_filter](#input\_log\_metric\_filter) | Configuration for the CloudWatch log metric filter submodule. Set to null to skip creating a log metric filter. |
object({
pattern = string
metric_transformation_name = string
metric_transformation_namespace = string
})
| `null` | no | -| [log\_stream](#input\_log\_stream) | Configuration for the CloudWatch log stream submodule. Set to null to skip creating a log stream. | `object({})` | `null` | no | -| [metric\_alarm](#input\_metric\_alarm) | Configuration for the CloudWatch metric alarm submodule. Set to null to skip creating a metric alarm. |
object({
comparison_operator = string
evaluation_periods = number
threshold = number
})
| `null` | no | -| [metric\_alarms\_by\_multiple\_dimensions](#input\_metric\_alarms\_by\_multiple\_dimensions) | Configuration for the CloudWatch metric alarms by multiple dimensions submodule. Set to null to skip creating these alarms. |
object({
comparison_operator = string
evaluation_periods = number
threshold = number
dimensions = map(map(string))
})
| `null` | 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 | -| [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 | -| [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 | -| [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 | -| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | -| [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 this module path. | `string` | `null` | no | -| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | 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 CloudWatch log group, if created. | -| [cloudwatch\_log\_group\_name](#output\_cloudwatch\_log\_group\_name) | Name of the CloudWatch log group, if created. | -| [cloudwatch\_log\_metric\_filter\_id](#output\_cloudwatch\_log\_metric\_filter\_id) | The name of the CloudWatch log metric filter, if created. | -| [cloudwatch\_log\_stream\_arn](#output\_cloudwatch\_log\_stream\_arn) | ARN of the CloudWatch log stream, if created. | -| [cloudwatch\_log\_stream\_name](#output\_cloudwatch\_log\_stream\_name) | Name of the CloudWatch log stream, if created. | -| [cloudwatch\_metric\_alarm\_arn](#output\_cloudwatch\_metric\_alarm\_arn) | The ARN of the CloudWatch metric alarm, if created. | -| [cloudwatch\_metric\_alarm\_arns](#output\_cloudwatch\_metric\_alarm\_arns) | Map of CloudWatch metric alarm ARNs created by the multiple-dimensions submodule, if configured. | -| [cloudwatch\_metric\_alarm\_id](#output\_cloudwatch\_metric\_alarm\_id) | The ID of the CloudWatch metric alarm, if created. | -| [cloudwatch\_metric\_alarm\_ids](#output\_cloudwatch\_metric\_alarm\_ids) | Map of CloudWatch metric alarm IDs created by the multiple-dimensions submodule, if configured. | - - - diff --git a/infrastructure/modules/cloudwatch/context.tf b/infrastructure/modules/cloudwatch/context.tf deleted file mode 100644 index 62befcb0..00000000 --- a/infrastructure/modules/cloudwatch/context.tf +++ /dev/null @@ -1,376 +0,0 @@ -# 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" { - 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 = {} - # 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. - 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 this module path." -} - -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 -} - -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 diff --git a/infrastructure/modules/cloudwatch/locals.tf b/infrastructure/modules/cloudwatch/locals.tf deleted file mode 100644 index bce20729..00000000 --- a/infrastructure/modules/cloudwatch/locals.tf +++ /dev/null @@ -1,27 +0,0 @@ -locals { - log_group_name = module.this.id - - log_stream_name = format("%s-stream", module.this.id) - - log_metric_filter_name = format("%s-metric-filter", module.this.id) - - metric_alarm_name = format("%s-alarm", module.this.id) - - metric_alarms_by_multiple_dimensions_name = format("%s-dimension-alarm", module.this.id) -} - -locals { - created_log_group_name = length(trimspace(try(module.log_group.cloudwatch_log_group_name, ""))) > 0 ? module.log_group.cloudwatch_log_group_name : null - - log_stream_log_group_name = local.created_log_group_name - - log_metric_filter_log_group_name = local.created_log_group_name - - metric_alarm_metric_name = try(var.log_metric_filter.metric_transformation_name, null) - - metric_alarm_namespace = try(var.log_metric_filter.metric_transformation_namespace, null) - - metric_alarms_by_multiple_dimensions_metric_name = try(var.log_metric_filter.metric_transformation_name, null) - - metric_alarms_by_multiple_dimensions_namespace = try(var.log_metric_filter.metric_transformation_namespace, null) -} diff --git a/infrastructure/modules/cloudwatch/main.tf b/infrastructure/modules/cloudwatch/main.tf deleted file mode 100644 index 71db3051..00000000 --- a/infrastructure/modules/cloudwatch/main.tf +++ /dev/null @@ -1,117 +0,0 @@ -################################################################ -# CloudWatch -# -# Thin NHS wrapper around the community CloudWatch submodules that -# provides a single entry point for the most common log and alarm -# building blocks used by screening teams: -# -# * log-group -# * log-stream -# * log-metric-filter -# * metric-alarm -# * metric-alarms-by-multiple-dimensions -# -# Naming and tagging are derived from context.tf via module.this. -################################################################ - -module "log_group" { - source = "terraform-aws-modules/cloudwatch/aws//modules/log-group" - version = "5.7.2" - - create = module.this.enabled && var.log_group != null - - name = local.log_group_name - - tags = module.this.tags -} - -module "log_stream" { - source = "terraform-aws-modules/cloudwatch/aws//modules/log-stream" - version = "5.7.2" - - create = module.this.enabled && var.log_stream != null - - name = local.log_stream_name - log_group_name = local.log_stream_log_group_name -} - -module "log_metric_filter" { - source = "terraform-aws-modules/cloudwatch/aws//modules/log-metric-filter" - version = "5.7.2" - - create_cloudwatch_log_metric_filter = module.this.enabled && var.log_metric_filter != null - - name = local.log_metric_filter_name - log_group_name = local.log_metric_filter_log_group_name - pattern = var.log_metric_filter.pattern - - metric_transformation_name = var.log_metric_filter.metric_transformation_name - metric_transformation_namespace = var.log_metric_filter.metric_transformation_namespace -} - -module "metric_alarm" { - source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarm" - version = "5.7.2" - - create_metric_alarm = module.this.enabled && var.metric_alarm != null - - alarm_name = local.metric_alarm_name - comparison_operator = var.metric_alarm.comparison_operator - evaluation_periods = var.metric_alarm.evaluation_periods - threshold = var.metric_alarm.threshold - - metric_name = local.metric_alarm_metric_name - namespace = local.metric_alarm_namespace - period = "60" - statistic = "Sum" - - tags = module.this.tags -} - -module "metric_alarms_by_multiple_dimensions" { - source = "terraform-aws-modules/cloudwatch/aws//modules/metric-alarms-by-multiple-dimensions" - version = "5.7.2" - - create_metric_alarm = module.this.enabled && var.metric_alarms_by_multiple_dimensions != null - - alarm_name = local.metric_alarms_by_multiple_dimensions_name - comparison_operator = var.metric_alarms_by_multiple_dimensions.comparison_operator - evaluation_periods = var.metric_alarms_by_multiple_dimensions.evaluation_periods - threshold = var.metric_alarms_by_multiple_dimensions.threshold - - metric_name = local.metric_alarms_by_multiple_dimensions_metric_name - namespace = local.metric_alarms_by_multiple_dimensions_namespace - period = "60" - statistic = "Sum" - dimensions = var.metric_alarms_by_multiple_dimensions.dimensions - - tags = module.this.tags -} - -check "log_stream_log_group_name" { - assert { - condition = var.log_stream == null || var.log_group != null - error_message = "log_stream requires log_group to be configured in the same module call." - } -} - -check "log_metric_filter_log_group_name" { - assert { - condition = var.log_metric_filter == null || var.log_group != null - error_message = "log_metric_filter requires log_group to be configured in the same module call." - } -} - -check "metric_alarm_metric_identity" { - assert { - condition = var.metric_alarm == null || (local.metric_alarm_metric_name != null && local.metric_alarm_namespace != null) - error_message = "metric_alarm requires metric_name and namespace, either directly on metric_alarm or indirectly from log_metric_filter." - } -} - -check "metric_alarms_by_multiple_dimensions_metric_identity" { - assert { - condition = var.metric_alarms_by_multiple_dimensions == null || (local.metric_alarms_by_multiple_dimensions_metric_name != null && local.metric_alarms_by_multiple_dimensions_namespace != null) - error_message = "metric_alarms_by_multiple_dimensions requires metric_name and namespace, either directly on metric_alarms_by_multiple_dimensions or indirectly from log_metric_filter." - } -} diff --git a/infrastructure/modules/cloudwatch/outputs.tf b/infrastructure/modules/cloudwatch/outputs.tf deleted file mode 100644 index 875cdba1..00000000 --- a/infrastructure/modules/cloudwatch/outputs.tf +++ /dev/null @@ -1,44 +0,0 @@ -output "cloudwatch_log_group_name" { - description = "Name of the CloudWatch log group, if created." - value = module.log_group.cloudwatch_log_group_name -} - -output "cloudwatch_log_group_arn" { - description = "ARN of the CloudWatch log group, if created." - value = module.log_group.cloudwatch_log_group_arn -} - -output "cloudwatch_log_stream_name" { - description = "Name of the CloudWatch log stream, if created." - value = module.log_stream.cloudwatch_log_stream_name -} - -output "cloudwatch_log_stream_arn" { - description = "ARN of the CloudWatch log stream, if created." - value = module.log_stream.cloudwatch_log_stream_arn -} - -output "cloudwatch_log_metric_filter_id" { - description = "The name of the CloudWatch log metric filter, if created." - value = module.log_metric_filter.cloudwatch_log_metric_filter_id -} - -output "cloudwatch_metric_alarm_id" { - description = "The ID of the CloudWatch metric alarm, if created." - value = module.metric_alarm.cloudwatch_metric_alarm_id -} - -output "cloudwatch_metric_alarm_arn" { - description = "The ARN of the CloudWatch metric alarm, if created." - value = module.metric_alarm.cloudwatch_metric_alarm_arn -} - -output "cloudwatch_metric_alarm_ids" { - description = "Map of CloudWatch metric alarm IDs created by the multiple-dimensions submodule, if configured." - value = module.metric_alarms_by_multiple_dimensions.cloudwatch_metric_alarm_ids -} - -output "cloudwatch_metric_alarm_arns" { - description = "Map of CloudWatch metric alarm ARNs created by the multiple-dimensions submodule, if configured." - value = module.metric_alarms_by_multiple_dimensions.cloudwatch_metric_alarm_arns -} diff --git a/infrastructure/modules/cloudwatch/variables.tf b/infrastructure/modules/cloudwatch/variables.tf deleted file mode 100644 index f69cf4b4..00000000 --- a/infrastructure/modules/cloudwatch/variables.tf +++ /dev/null @@ -1,49 +0,0 @@ -################################################################ -# CloudWatch submodule inputs. -# -# Naming, tagging and the master `enabled` switch come from -# context.tf via `module.this`. -################################################################ - -variable "log_group" { - description = "Configuration for the CloudWatch log group submodule. Set to null to skip creating a log group." - type = object({}) - default = null -} - -variable "log_stream" { - description = "Configuration for the CloudWatch log stream submodule. Set to null to skip creating a log stream." - type = object({}) - default = null -} - -variable "log_metric_filter" { - description = "Configuration for the CloudWatch log metric filter submodule. Set to null to skip creating a log metric filter." - type = object({ - pattern = string - metric_transformation_name = string - metric_transformation_namespace = string - }) - default = null -} - -variable "metric_alarm" { - description = "Configuration for the CloudWatch metric alarm submodule. Set to null to skip creating a metric alarm." - type = object({ - comparison_operator = string - evaluation_periods = number - threshold = number - }) - default = null -} - -variable "metric_alarms_by_multiple_dimensions" { - description = "Configuration for the CloudWatch metric alarms by multiple dimensions submodule. Set to null to skip creating these alarms." - type = object({ - comparison_operator = string - evaluation_periods = number - threshold = number - dimensions = map(map(string)) - }) - default = null -} diff --git a/infrastructure/modules/cloudwatch/versions.tf b/infrastructure/modules/cloudwatch/versions.tf deleted file mode 100644 index cb30fe5c..00000000 --- a/infrastructure/modules/cloudwatch/versions.tf +++ /dev/null @@ -1,10 +0,0 @@ -terraform { - required_version = ">= 1.13" - - required_providers { - aws = { - source = "hashicorp/aws" - version = ">= 6.42" - } - } -} From 58afa4ce488c086141192e2a773a7c36acf58091 Mon Sep 17 00:00:00 2001 From: Pira-nhs Date: Wed, 1 Jul 2026 16:54:39 +0100 Subject: [PATCH 077/118] refactor(dependabot): remove deprecated cloudwatch module from updates refactor(readme): remove cloudwatch entry from module list --- .github/dependabot.yaml | 1 - README.md | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 899c7e8f..b6ab869c 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -43,7 +43,6 @@ updates: - "infrastructure/modules/cloudwatch-log-metric-filter" - "infrastructure/modules/cloudwatch-logs" - "infrastructure/modules/cloudwatch-metric-alarm" - - "infrastructure/modules/cloudwatch" - "infrastructure/modules/cognito" - "infrastructure/modules/cw-firehose-splunk" - "infrastructure/modules/ecr" diff --git a/README.md b/README.md index 194a26eb..6c8fd703 100644 --- a/README.md +++ b/README.md @@ -307,7 +307,6 @@ Rules: | `aws-backup-destination` | — | AWS Backup destination vault | | `aws-backup-source` | — | AWS Backup source configuration | | `aws-scheduler` | — | EventBridge Scheduler configuration | -| `cloudwatch` | terraform-aws-modules/cloudwatch/aws | CloudWatch log groups, streams, metric filters, and alarms | | `cloudwatch-log-metric-filter` | terraform-aws-modules/cloudwatch/aws | CloudWatch log metric filters (emits metrics from log patterns) | | `cloudwatch-logs` | terraform-aws-modules/cloudwatch/aws | CloudWatch log groups and streams | | `cloudwatch-metric-alarm` | terraform-aws-modules/cloudwatch/aws | CloudWatch metric alarms (single and multi-dimension) | From 434ad1daa894f8e616626c6b60017f058630dc1d Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 2 Jul 2026 14:59:59 +0100 Subject: [PATCH 078/118] Added precondition for performance insights --- infrastructure/modules/rds/README.md | 1 + infrastructure/modules/rds/main.tf | 5 +++++ infrastructure/modules/rds/variables.tf | 7 +++++-- 3 files changed, 11 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 15bf4786..2d81e369 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -14,6 +14,7 @@ The module provisions an RDS DB instance together with its subnet group, paramet |`auto_minor_version_upgrade`|`false`|Teams keep instances in sync with the production engine version| |`create_db_subnet_group`|`true`|subnet group is always managed by this module| |`vpc_security_group_ids`|Non-empty list required|RDS must not rely on the default VPC security group; callers must always supply at least one explicit security group| +|`performance_insights_kms_key_id`|Required when Performance Insights enabled|AWS-managed keys are not acceptable per platform policy; a customer-managed KMS key must always be supplied| |Creation gate|`module.this.enabled`|Prevents all managed RDS resources when disabled| ## Usage diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 2a9bfb37..9037122c 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -31,6 +31,11 @@ resource "terraform_data" "validate_inputs" { condition = length(var.vpc_security_group_ids) > 0 error_message = "vpc_security_group_ids must not be empty. Create a security group using the dedicated security group module and pass its ID here." } + + precondition { + condition = !var.performance_insights_enabled || var.performance_insights_kms_key_id != null + error_message = "performance_insights_kms_key_id must be set when performance_insights_enabled is true. AWS-managed keys are not acceptable per platform policy." + } } } diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index d95beaf9..e4ca2841 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -226,8 +226,11 @@ variable "monitoring_interval" { } } +# Note: The Performance Insights console is transitioning to CloudWatch Database +# Insights (end-of-life July 31, 2026). Terraform API parameters are fully +# preserved and continue to work unchanged after the transition. variable "performance_insights_enabled" { - description = "Enable Performance Insights" + description = "Enable Performance Insights (Standard mode of CloudWatch Database Insights). When true, performance_insights_kms_key_id must also be set." type = bool default = true } @@ -248,7 +251,7 @@ variable "performance_insights_retention_period" { } variable "performance_insights_kms_key_id" { - description = "ARN of the KMS key used to encrypt Performance Insights data. If omitted, the default KMS key is used" + description = "ARN of the customer-managed KMS key used to encrypt Performance Insights data. Required when performance_insights_enabled is true. AWS-managed keys are not acceptable per platform policy." type = string default = null } From 0e03a3bc1497ee6afaf08b3698ca83d74ba369ff Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 2 Jul 2026 15:16:54 +0100 Subject: [PATCH 079/118] Replaced preconditions with validation blocks in variables.tf --- infrastructure/modules/rds/main.tf | 28 ------------------------- infrastructure/modules/rds/variables.tf | 20 ++++++++++++++++++ 2 files changed, 20 insertions(+), 28 deletions(-) diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf index 9037122c..30efc083 100644 --- a/infrastructure/modules/rds/main.tf +++ b/infrastructure/modules/rds/main.tf @@ -11,34 +11,6 @@ # - create_db_subnet_group = true (subnet group always managed by this module) # ---------------------------------------------------------------------------- -# Cross-variable validation — enforces rules that cannot be expressed inside -# individual variable validation blocks because they reference multiple variables. -resource "terraform_data" "validate_inputs" { - count = module.this.enabled ? 1 : 0 - - lifecycle { - precondition { - condition = var.manage_master_user_password || var.snapshot_identifier != null || var.password_wo != null - error_message = "password_wo must be provided when manage_master_user_password is false and snapshot_identifier is not set." - } - - precondition { - condition = var.snapshot_identifier == null || var.character_set_name == null - error_message = "character_set_name must be null when restoring from a snapshot (snapshot_identifier is set)." - } - - precondition { - condition = length(var.vpc_security_group_ids) > 0 - error_message = "vpc_security_group_ids must not be empty. Create a security group using the dedicated security group module and pass its ID here." - } - - precondition { - condition = !var.performance_insights_enabled || var.performance_insights_kms_key_id != null - error_message = "performance_insights_kms_key_id must be set when performance_insights_enabled is true. AWS-managed keys are not acceptable per platform policy." - } - } -} - module "rds" { source = "terraform-aws-modules/rds/aws" version = "7.2.0" diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index e4ca2841..e63da7a5 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -32,6 +32,11 @@ variable "character_set_name" { description = "Oracle character set name. Cannot be changed after creation. Must be null when restoring from a snapshot" type = string default = null + + validation { + condition = var.snapshot_identifier == null || var.character_set_name == null + error_message = "character_set_name must be null when restoring from a snapshot (snapshot_identifier is set)." + } } # ---------------------------------------------------------------------------- @@ -87,6 +92,11 @@ variable "password_wo" { type = string default = null sensitive = true + + validation { + condition = var.manage_master_user_password || var.snapshot_identifier != null || var.password_wo != null + error_message = "password_wo must be provided when manage_master_user_password is false and snapshot_identifier is not set." + } } variable "password_wo_version" { @@ -129,6 +139,11 @@ variable "vpc_security_group_ids" { description = "List of security group IDs to associate with the instance. Create the security group using the dedicated security group module and pass its ID here" type = list(string) default = [] + + validation { + condition = length(var.vpc_security_group_ids) > 0 + error_message = "vpc_security_group_ids must not be empty. Create a security group using the dedicated security group module and pass its ID here." + } } # ---------------------------------------------------------------------------- @@ -254,6 +269,11 @@ variable "performance_insights_kms_key_id" { description = "ARN of the customer-managed KMS key used to encrypt Performance Insights data. Required when performance_insights_enabled is true. AWS-managed keys are not acceptable per platform policy." type = string default = null + + validation { + condition = !var.performance_insights_enabled || var.performance_insights_kms_key_id != null + error_message = "performance_insights_kms_key_id must be set when performance_insights_enabled is true. AWS-managed keys are not acceptable per platform policy." + } } # ---------------------------------------------------------------------------- From de4c331bfdca3368564d73f7fb10e93d4530d779 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 2 Jul 2026 16:37:42 +0100 Subject: [PATCH 080/118] Added missing validation --- infrastructure/modules/rds/variables.tf | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index e63da7a5..790bc543 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -72,9 +72,14 @@ variable "iops" { } variable "kms_key_id" { - description = "ARN of the KMS key for storage encryption. If omitted, the default account KMS key is used" + description = "ARN of the customer-managed KMS key for storage encryption. AWS-managed keys are not acceptable per platform policy." type = string default = null + + validation { + condition = var.kms_key_id != null + error_message = "kms_key_id must be set. AWS-managed keys are not acceptable per platform policy." + } } # ---------------------------------------------------------------------------- From 2a4b0fd01a82e64a238650c49cb4975de02a227d Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 2 Jul 2026 16:38:37 +0100 Subject: [PATCH 081/118] Attempted format fix for README --- infrastructure/modules/rds/README.md | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index 2d81e369..d3b33a3f 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -316,7 +316,6 @@ No resources. | [backup\_window](#input\_backup\_window) | Daily UTC time range for automated backups (e.g. '23:00-23:30'). Must not overlap with maintenance\_window | `string` | `"23:00-23:30"` | no | | [character\_set\_name](#input\_character\_set\_name) | Oracle character set name. Cannot be changed after creation. Must be null when restoring from a snapshot | `string` | `null` | 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 | -| [custom\_name](#input\_custom\_name) | Optional override name for the RDS instance. Takes precedence over identifier when set. | `string` | `null` | no | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | | [data\_type](#input\_data\_type) | The tag data\_type | `string` | `"None"` | no | | [db\_name](#input\_db\_name) | The name of the database to create. Omit to skip initial database creation | `string` | `null` | no | @@ -329,10 +328,10 @@ No resources. | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | | [family](#input\_family) | DB parameter group family (e.g. 'oracle-ee-19', 'postgres16', 'mysql8.0') | `string` | n/a | yes | | [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 | -| [identifier](#input\_identifier) | Explicit name for the RDS instance. If null, this module derives the name from context labels. | `string` | `null` | no | +| [identifier](#input\_identifier) | Explicit identifier for the RDS instance. When set, overrides the name derived from context labels. Use this when migrating from an existing instance that already has a specific identifier, or when the context-derived name would be too long for RDS. | `string` | `null` | no | | [instance\_class](#input\_instance\_class) | The instance type of the RDS instance (e.g. 'db.m5.large') | `string` | n/a | yes | | [iops](#input\_iops) | Provisioned IOPS. Required when storage\_type is 'io1' or 'io2' | `number` | `null` | no | -| [kms\_key\_id](#input\_kms\_key\_id) | ARN of the KMS key for storage encryption. If omitted, the default account KMS key is used | `string` | `null` | no | +| [kms\_key\_id](#input\_kms\_key\_id) | ARN of the customer-managed KMS key for storage encryption. AWS-managed keys are not acceptable per platform policy. | `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 | @@ -351,8 +350,8 @@ No resources. | [parameters](#input\_parameters) | List of DB parameters to apply to the parameter group |
list(object({
name = string
value = string
apply_method = optional(string)
}))
| `[]` | no | | [password\_wo](#input\_password\_wo) | Write-only password for the master DB user. Required when manage\_master\_user\_password is false and snapshot\_identifier is not set | `string` | `null` | no | | [password\_wo\_version](#input\_password\_wo\_version) | Increment this value to trigger a password rotation when password\_wo changes | `number` | `1` | no | -| [performance\_insights\_enabled](#input\_performance\_insights\_enabled) | Enable Performance Insights | `bool` | `true` | no | -| [performance\_insights\_kms\_key\_id](#input\_performance\_insights\_kms\_key\_id) | ARN of the KMS key used to encrypt Performance Insights data. If omitted, the default KMS key is used | `string` | `null` | no | +| [performance\_insights\_enabled](#input\_performance\_insights\_enabled) | Enable Performance Insights (Standard mode of CloudWatch Database Insights). When true, performance\_insights\_kms\_key\_id must also be set. | `bool` | `true` | no | +| [performance\_insights\_kms\_key\_id](#input\_performance\_insights\_kms\_key\_id) | ARN of the customer-managed KMS key used to encrypt Performance Insights data. Required when performance\_insights\_enabled is true. AWS-managed keys are not acceptable per platform policy. | `string` | `null` | no | | [performance\_insights\_retention\_period](#input\_performance\_insights\_retention\_period) | Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31 | `number` | `7` | no | | [port](#input\_port) | The port on which the DB accepts connections | `number` | n/a | yes | | [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 | From c6680775ee4ddc23e94667ab1ac150525ff2ed31 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Thu, 2 Jul 2026 16:52:22 +0100 Subject: [PATCH 082/118] Added description and the error message reading 7, 731 (2 years), or a multiple of 31. --- infrastructure/modules/rds/variables.tf | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/rds/variables.tf b/infrastructure/modules/rds/variables.tf index 790bc543..7649da94 100644 --- a/infrastructure/modules/rds/variables.tf +++ b/infrastructure/modules/rds/variables.tf @@ -256,7 +256,7 @@ variable "performance_insights_enabled" { } variable "performance_insights_retention_period" { - description = "Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31" + description = "Retention period for Performance Insights data in days. Valid values: 7, 731 (2 years), or a multiple of 31" type = number default = 7 @@ -266,7 +266,7 @@ variable "performance_insights_retention_period" { var.performance_insights_retention_period == 731 || (var.performance_insights_retention_period % 31 == 0 && var.performance_insights_retention_period >= 31) ) - error_message = "performance_insights_retention_period must be 7, 731, or a multiple of 31 (e.g. 31, 62, 93)." + error_message = "performance_insights_retention_period must be 7, 731 (2 years), or a multiple of 31 (e.g. 31, 62, 93)." } } From 45fefdd0249f9751d62720152dfdd690032699aa Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Fri, 3 Jul 2026 12:11:25 +0100 Subject: [PATCH 083/118] Attempt to fix README error --- infrastructure/modules/rds/README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/infrastructure/modules/rds/README.md b/infrastructure/modules/rds/README.md index d3b33a3f..11e589ef 100644 --- a/infrastructure/modules/rds/README.md +++ b/infrastructure/modules/rds/README.md @@ -352,7 +352,7 @@ No resources. | [password\_wo\_version](#input\_password\_wo\_version) | Increment this value to trigger a password rotation when password\_wo changes | `number` | `1` | no | | [performance\_insights\_enabled](#input\_performance\_insights\_enabled) | Enable Performance Insights (Standard mode of CloudWatch Database Insights). When true, performance\_insights\_kms\_key\_id must also be set. | `bool` | `true` | no | | [performance\_insights\_kms\_key\_id](#input\_performance\_insights\_kms\_key\_id) | ARN of the customer-managed KMS key used to encrypt Performance Insights data. Required when performance\_insights\_enabled is true. AWS-managed keys are not acceptable per platform policy. | `string` | `null` | no | -| [performance\_insights\_retention\_period](#input\_performance\_insights\_retention\_period) | Retention period for Performance Insights data in days. Valid values: 7, 731, or a multiple of 31 | `number` | `7` | no | +| [performance\_insights\_retention\_period](#input\_performance\_insights\_retention\_period) | Retention period for Performance Insights data in days. Valid values: 7, 731 (2 years), or a multiple of 31 | `number` | `7` | no | | [port](#input\_port) | The port on which the DB accepts connections | `number` | n/a | yes | | [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 | From dab48d7d1504d061aa6300586b2de8c8ef838f87 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Fri, 3 Jul 2026 12:15:41 +0100 Subject: [PATCH 084/118] Moved locals block to locals.tf --- infrastructure/modules/alb/locals.tf | 17 +++++++++++++++++ infrastructure/modules/alb/main.tf | 18 ------------------ 2 files changed, 17 insertions(+), 18 deletions(-) create mode 100644 infrastructure/modules/alb/locals.tf diff --git a/infrastructure/modules/alb/locals.tf b/infrastructure/modules/alb/locals.tf new file mode 100644 index 00000000..95914892 --- /dev/null +++ b/infrastructure/modules/alb/locals.tf @@ -0,0 +1,17 @@ +locals { + # Inject an HTTP → HTTPS redirect listener on port 80 when enabled (ALB only). + # Callers can override by providing their own "http-redirect" key in var.listeners. + http_redirect_listener = var.enable_http_https_redirect && var.load_balancer_type == "application" ? { + http-redirect = { + port = 80 + protocol = "HTTP" + redirect = { + port = "443" + protocol = "HTTPS" + status_code = "HTTP_301" + } + } + } : {} + + effective_listeners = merge(local.http_redirect_listener, var.listeners) +} diff --git a/infrastructure/modules/alb/main.tf b/infrastructure/modules/alb/main.tf index 6472f303..9931e70c 100644 --- a/infrastructure/modules/alb/main.tf +++ b/infrastructure/modules/alb/main.tf @@ -15,24 +15,6 @@ # - drop_invalid_header_fields → always true (ALB); null (NLB) ################################################################ -locals { - # Inject an HTTP → HTTPS redirect listener on port 80 when enabled (ALB only). - # Callers can override by providing their own "http-redirect" key in var.listeners. - http_redirect_listener = var.enable_http_https_redirect && var.load_balancer_type == "application" ? { - http-redirect = { - port = 80 - protocol = "HTTP" - redirect = { - port = "443" - protocol = "HTTPS" - status_code = "HTTP_301" - } - } - } : {} - - effective_listeners = merge(local.http_redirect_listener, var.listeners) -} - module "alb" { source = "terraform-aws-modules/alb/aws" version = "10.5.0" From de2eb7a4e1bb09805db774e2e4d3030b560eb7de Mon Sep 17 00:00:00 2001 From: dave4420 Date: Fri, 3 Jul 2026 12:58:25 +0100 Subject: [PATCH 085/118] feat(ec2-instance): BCSS-23594 - add EC2-instance module (#54) --- .github/dependabot.yaml | 1 + README.md | 1 + .../modules/ec2-instance/.terraform.lock.hcl | 30 + infrastructure/modules/ec2-instance/README.md | 252 ++++++++ .../modules/ec2-instance/context.tf | 375 +++++++++++ infrastructure/modules/ec2-instance/main.tf | 88 +++ .../modules/ec2-instance/outputs.tf | 149 +++++ .../modules/ec2-instance/variables.tf | 588 ++++++++++++++++++ .../modules/ec2-instance/versions.tf | 10 + 9 files changed, 1494 insertions(+) create mode 100644 infrastructure/modules/ec2-instance/.terraform.lock.hcl create mode 100644 infrastructure/modules/ec2-instance/README.md create mode 100644 infrastructure/modules/ec2-instance/context.tf create mode 100644 infrastructure/modules/ec2-instance/main.tf create mode 100644 infrastructure/modules/ec2-instance/outputs.tf create mode 100644 infrastructure/modules/ec2-instance/variables.tf create mode 100644 infrastructure/modules/ec2-instance/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index d3abac03..4129e961 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -42,6 +42,7 @@ updates: - "infrastructure/modules/aws-scheduler" - "infrastructure/modules/cognito" - "infrastructure/modules/cw-firehose-splunk" + - "infrastructure/modules/ec2-instance" - "infrastructure/modules/ecr" - "infrastructure/modules/ecs-cluster" - "infrastructure/modules/elasticache" diff --git a/README.md b/README.md index 2fa4fc96..115f0da4 100644 --- a/README.md +++ b/README.md @@ -308,6 +308,7 @@ Rules: | `aws-scheduler` | — | EventBridge Scheduler configuration | | `cognito` | — | Cognito user and identity pools | | `cw-firehose-splunk` | — | CloudWatch logs to Splunk via Firehose | +| `ec2-instance` | — | — | | `ecr` | — | ECR repository with security controls | | `ecs-cluster` | terraform-aws-modules/ecs/aws//modules/cluster | ECS Fargate cluster | | `elasticache` | — | ElastiCache cluster (Redis/Memcached) | diff --git a/infrastructure/modules/ec2-instance/.terraform.lock.hcl b/infrastructure/modules/ec2-instance/.terraform.lock.hcl new file mode 100644 index 00000000..af1557ee --- /dev/null +++ b/infrastructure/modules/ec2-instance/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.14.0, >= 6.37.0" + hashes = [ + "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:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} diff --git a/infrastructure/modules/ec2-instance/README.md b/infrastructure/modules/ec2-instance/README.md new file mode 100644 index 00000000..750015eb --- /dev/null +++ b/infrastructure/modules/ec2-instance/README.md @@ -0,0 +1,252 @@ +# EC2-Instance + +NHS Screening wrapper around the community +[`terraform-aws-modules/ec2-instance/aws`](https://registry.terraform.io/modules/terraform-aws-modules/ec2-instance/aws/latest) +module that consumes the shared `context.tf` for naming and tagging. + +## Usage + +### Minimal instance + +```hcl +module "app_instance" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ec2-instance?ref=main" + + service = "bcss" + project = "platform" + environment = "development" + name = "app" + + ami = "ami-0123456789abcdef0" + subnet_id = "subnet-0123456789abcdef0" + vpc_security_group_ids = ["sg-0123456789abcdef0"] +} +``` + +### Instance with iam profile and SSH key + +```hcl +module "ops_instance" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ec2-instance?ref=main" + + service = "bcss" + project = "operations" + environment = "test" + name = "ops" + + ami = "ami-0123456789abcdef0" + instance_type = "t3.small" + subnet_id = "subnet-0123456789abcdef0" + vpc_security_group_ids = ["sg-0123456789abcdef0"] + + iam_instance_profile = "ec2-ops-profile" + key_name = "screening-ops" +} +``` + +### Instance with custom root volume and user data + +```hcl +module "worker_instance" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ec2-instance?ref=main" + + service = "bcss" + project = "batch" + environment = "prod" + name = "worker" + + ami = "ami-0123456789abcdef0" + instance_type = "t3.medium" + subnet_id = "subnet-0123456789abcdef0" + vpc_security_group_ids = ["sg-0123456789abcdef0"] + + root_block_device = { + encrypted = true + size = 50 + type = "gp3" + } + + user_data = <<-EOT + #!/bin/bash + set -euo pipefail + yum update -y + EOT +} +``` + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [ec2\_instance](#module\_ec2\_instance) | terraform-aws-modules/ec2-instance/aws | 6.4.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 | +| [ami](#input\_ami) | ID of AMI to use for the instance | `string` | `null` | no | +| [ami\_ssm\_parameter](#input\_ami\_ssm\_parameter) | SSM parameter name for the AMI ID. For Amazon Linux AMI SSM parameters see https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-public-parameters-ami.html | `string` | `"/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64"` | no | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [associate\_public\_ip\_address](#input\_associate\_public\_ip\_address) | Whether to associate a public IP address with an instance in a VPC | `bool` | `null` | 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 | +| [availability\_zone](#input\_availability\_zone) | The AZ to start the instance in | `string` | `null` | no | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [capacity\_reservation\_specification](#input\_capacity\_reservation\_specification) | Describes an instance's Capacity Reservation targeting option |
object({
capacity_reservation_preference = optional(string)
capacity_reservation_target = optional(object({
capacity_reservation_id = optional(string)
capacity_reservation_resource_group_arn = optional(string)
}))
})
| `null` | 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 | +| [cpu\_credits](#input\_cpu\_credits) | The credit option for CPU usage (unlimited or standard) | `string` | `null` | no | +| [cpu\_options](#input\_cpu\_options) | Defines CPU options to apply to the instance at launch time. |
object({
amd_sev_snp = optional(string)
core_count = optional(number)
nested_virtualization = optional(string)
threads_per_core = optional(number)
})
| `null` | no | +| [create\_eip](#input\_create\_eip) | Determines whether a public EIP will be created and associated with the instance. | `bool` | `false` | no | +| [create\_iam\_instance\_profile](#input\_create\_iam\_instance\_profile) | Determines whether an IAM instance profile is created or to use an existing IAM instance profile | `bool` | `false` | no | +| [create\_security\_group](#input\_create\_security\_group) | Determines whether a security group will be created | `bool` | `true` | no | +| [create\_spot\_instance](#input\_create\_spot\_instance) | Depicts if the instance is a spot instance | `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\_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 | +| [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 | +| [disable\_api\_stop](#input\_disable\_api\_stop) | If true, enables EC2 Instance Stop Protection | `bool` | `null` | no | +| [disable\_api\_termination](#input\_disable\_api\_termination) | If true, enables EC2 Instance Termination Protection | `bool` | `null` | no | +| [ebs\_optimized](#input\_ebs\_optimized) | If true, the launched EC2 instance will be EBS-optimized | `bool` | `null` | no | +| [ebs\_volumes](#input\_ebs\_volumes) | Map of EBS volumes to attach to the instance |
map(object({
encrypted = optional(bool)
final_snapshot = optional(bool)
iops = optional(number)
kms_key_id = optional(string)
multi_attach_enabled = optional(bool)
outpost_arn = optional(string)
size = optional(number)
snapshot_id = optional(string)
tags = optional(map(string), {})
throughput = optional(number)
type = optional(string, "gp3")
volume_initialization_rate = optional(number)
# Attachment
device_name = optional(string) # Will fall back to use map key as device name
force_detach = optional(bool)
skip_destroy = optional(bool)
stop_instance_before_detaching = optional(bool)
}))
| `null` | no | +| [eip\_domain](#input\_eip\_domain) | Indicates if this EIP is for use in VPC | `string` | `"vpc"` | no | +| [eip\_tags](#input\_eip\_tags) | A map of additional tags to add to the EIP | `map(string)` | `{}` | no | +| [enable\_primary\_ipv6](#input\_enable\_primary\_ipv6) | Whether to assign a primary IPv6 Global Unicast Address (GUA) to the instance when launched in a dual-stack or IPv6-only subnet | `bool` | `null` | no | +| [enable\_volume\_tags](#input\_enable\_volume\_tags) | Whether to enable volume tags (if enabled it conflicts with root\_block\_device tags) | `bool` | `true` | no | +| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | +| [enclave\_options\_enabled](#input\_enclave\_options\_enabled) | Whether Nitro Enclaves will be enabled on the instance. Defaults to `false` | `bool` | `null` | no | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [ephemeral\_block\_device](#input\_ephemeral\_block\_device) | Customize Ephemeral (also known as Instance Store) volumes on the instance |
map(object({
device_name = optional(string)
no_device = optional(bool)
virtual_name = optional(string)
}))
| `null` | no | +| [force\_destroy](#input\_force\_destroy) | Destroys instance even if `disable_api_termination` or `disable_api_stop` is set to true. Once this parameter is set to true, a successful terraform apply run before a destroy is required to update this value in the resource state. Without a successful terraform apply after this parameter is set, this flag will have no effect. If setting this field in the same operation that would require replacing the instance or destroying the instance, this flag will not work. Additionally when importing an instance, a successful terraform apply is required to set this value in state before it will take effect on a destroy operation. | `bool` | `null` | no | +| [get\_password\_data](#input\_get\_password\_data) | If true, wait for password data to become available and retrieve it | `bool` | `null` | no | +| [hibernation](#input\_hibernation) | If true, the launched EC2 instance will support hibernation | `bool` | `null` | no | +| [host\_id](#input\_host\_id) | ID of a dedicated host that the instance will be assigned to. Use when an instance is to be launched on a specific dedicated host | `string` | `null` | no | +| [host\_resource\_group\_arn](#input\_host\_resource\_group\_arn) | ARN of the host resource group in which to launch the instances. If you specify an ARN, omit the `tenancy` parameter or set it to `host` | `string` | `null` | no | +| [iam\_instance\_profile](#input\_iam\_instance\_profile) | IAM Instance Profile to launch the instance with. Specified as the name of the Instance Profile | `string` | `null` | no | +| [iam\_role\_description](#input\_iam\_role\_description) | Description of the role | `string` | `null` | no | +| [iam\_role\_name](#input\_iam\_role\_name) | Name to use on IAM role created | `string` | `null` | no | +| [iam\_role\_path](#input\_iam\_role\_path) | IAM role path | `string` | `null` | no | +| [iam\_role\_permissions\_boundary](#input\_iam\_role\_permissions\_boundary) | ARN of the policy that is used to set the permissions boundary for the IAM role | `string` | `null` | no | +| [iam\_role\_policies](#input\_iam\_role\_policies) | Policies attached to the IAM role | `map(string)` | `{}` | no | +| [iam\_role\_tags](#input\_iam\_role\_tags) | A map of additional tags to add to the IAM role/profile created | `map(string)` | `{}` | no | +| [iam\_role\_use\_name\_prefix](#input\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`iam_role_name` or `name`) is used as a prefix | `bool` | `true` | 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 | +| [ignore\_ami\_changes](#input\_ignore\_ami\_changes) | Whether changes to the AMI ID changes should be ignored by Terraform. Note - changing this value will result in the replacement of the instance | `bool` | `false` | no | +| [instance\_initiated\_shutdown\_behavior](#input\_instance\_initiated\_shutdown\_behavior) | Shutdown behavior for the instance. Amazon defaults this to stop for EBS-backed instances and terminate for instance-store instances. Cannot be set on instance-store instance | `string` | `null` | no | +| [instance\_market\_options](#input\_instance\_market\_options) | The market (purchasing) option for the instance. If set, overrides the `create_spot_instance` variable |
object({
market_type = optional(string)
spot_options = optional(object({
instance_interruption_behavior = optional(string)
max_price = optional(string)
spot_instance_type = optional(string)
valid_until = optional(string)
}))
})
| `null` | no | +| [instance\_tags](#input\_instance\_tags) | Additional tags for the instance | `map(string)` | `{}` | no | +| [instance\_type](#input\_instance\_type) | The type of instance to start | `string` | `"t3.micro"` | no | +| [ipv6\_address\_count](#input\_ipv6\_address\_count) | A number of IPv6 addresses to associate with the primary network interface. Amazon EC2 chooses the IPv6 addresses from the range of your subnet | `number` | `null` | no | +| [ipv6\_addresses](#input\_ipv6\_addresses) | Specify one or more IPv6 addresses from the range of the subnet to associate with the primary network interface | `list(string)` | `null` | no | +| [key\_name](#input\_key\_name) | Name of the Key Pair to use for the instance; which can be managed using the `aws_key_pair` resource | `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 | +| [launch\_template](#input\_launch\_template) | Specifies a Launch Template to configure the instance. Parameters configured on this resource will override the corresponding parameters in the Launch Template |
object({
id = optional(string)
name = optional(string)
version = optional(string)
})
| `null` | no | +| [maintenance\_options](#input\_maintenance\_options) | The maintenance options for the instance |
object({
auto_recovery = optional(string)
})
| `null` | no | +| [metadata\_options](#input\_metadata\_options) | Customize the metadata options of the instance |
object({
http_endpoint = optional(string, "enabled")
http_protocol_ipv6 = optional(string)
http_put_response_hop_limit = optional(number, 1)
http_tokens = optional(string, "required")
instance_metadata_tags = optional(string)
})
| `{}` | no | +| [monitoring](#input\_monitoring) | If true, the launched EC2 instance will have detailed monitoring enabled | `bool` | `null` | 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 | +| [network\_interface](#input\_network\_interface) | Customize network interfaces to be attached at instance boot time |
map(object({
delete_on_termination = optional(bool)
device_index = optional(number) # Will fall back to use map key as device index
network_card_index = optional(number)
network_interface_id = string
}))
| `null` | 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 | +| [placement\_group](#input\_placement\_group) | The Placement Group to start the instance in | `string` | `null` | no | +| [placement\_group\_id](#input\_placement\_group\_id) | Placement Group ID to start the instance in | `string` | `null` | no | +| [placement\_partition\_number](#input\_placement\_partition\_number) | Number of the partition the instance is in. Valid only if the `aws_placement_group` resource's `strategy` argument is set to `partition` | `number` | `null` | no | +| [private\_dns\_name\_options](#input\_private\_dns\_name\_options) | Customize the private DNS name options of the instance |
object({
enable_resource_name_dns_aaaa_record = optional(bool)
enable_resource_name_dns_a_record = optional(bool)
hostname_type = optional(string)
})
| `null` | no | +| [private\_ip](#input\_private\_ip) | The private IP address to associate with the instance in a VPC | `string` | `null` | 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 | +| [root\_block\_device](#input\_root\_block\_device) | Customize details about the root block device of the instance |
object({
delete_on_termination = optional(bool)
encrypted = optional(bool)
iops = optional(number)
kms_key_id = optional(string)
tags = optional(map(string))
throughput = optional(number)
size = optional(number)
type = optional(string)
})
| `null` | no | +| [secondary\_network\_interface](#input\_secondary\_network\_interface) | Customize secondary network interfaces to be attached to the EC2 instance |
map(object({
delete_on_termination = optional(bool)
device_index = optional(number) # Will fall back to use map key as device index
interface_type = optional(string)
network_card_index = number
private_ip_address_count = optional(number)
private_ip_addresses = optional(list(string))
secondary_subnet_id = string
}))
| `null` | no | +| [secondary\_private\_ips](#input\_secondary\_private\_ips) | A list of secondary private IPv4 addresses to assign to the instance's primary network interface (eth0) in a VPC. Can only be assigned to the primary network interface (eth0) attached at instance creation, not a pre-existing network interface i.e. referenced in a `network_interface block` | `list(string)` | `null` | no | +| [security\_group\_description](#input\_security\_group\_description) | Description of the security group | `string` | `null` | no | +| [security\_group\_egress\_rules](#input\_security\_group\_egress\_rules) | Egress rules to add to the security group |
map(object({
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
from_port = optional(number)
ip_protocol = optional(string, "tcp")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
to_port = optional(number)
}))
|
{
"ipv4_default": {
"cidr_ipv4": "0.0.0.0/0",
"description": "Allow all IPv4 traffic",
"ip_protocol": "-1"
},
"ipv6_default": {
"cidr_ipv6": "::/0",
"description": "Allow all IPv6 traffic",
"ip_protocol": "-1"
}
}
| no | +| [security\_group\_ingress\_rules](#input\_security\_group\_ingress\_rules) | Ingress rules to add to the security group |
map(object({
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
from_port = optional(number)
ip_protocol = optional(string, "tcp")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
to_port = optional(number)
}))
| `null` | no | +| [security\_group\_name](#input\_security\_group\_name) | Name to use on security group created | `string` | `null` | no | +| [security\_group\_tags](#input\_security\_group\_tags) | A map of additional tags to add to the security group created | `map(string)` | `{}` | no | +| [security\_group\_use\_name\_prefix](#input\_security\_group\_use\_name\_prefix) | Determines whether the security group name (`security_group_name` or `name`) is used as a prefix | `bool` | `true` | no | +| [security\_group\_vpc\_id](#input\_security\_group\_vpc\_id) | VPC ID to create the security group in. If not set, the security group will be created in the default VPC | `string` | `null` | 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 | +| [source\_dest\_check](#input\_source\_dest\_check) | Controls if traffic is routed to the instance when the destination address does not match the instance. Used for NAT or VPNs | `bool` | `null` | no | +| [spot\_instance\_interruption\_behavior](#input\_spot\_instance\_interruption\_behavior) | Indicates Spot instance behavior when it is interrupted. Valid values are `terminate`, `stop`, or `hibernate` | `string` | `null` | no | +| [spot\_launch\_group](#input\_spot\_launch\_group) | A launch group is a group of spot instances that launch together and terminate together. If left empty instances are launched and terminated individually | `string` | `null` | no | +| [spot\_price](#input\_spot\_price) | The maximum price to request on the spot market. Defaults to on-demand price | `string` | `null` | no | +| [spot\_type](#input\_spot\_type) | If set to one-time, after the instance is terminated, the spot request will be closed. Default `persistent` | `string` | `null` | no | +| [spot\_valid\_from](#input\_spot\_valid\_from) | The start date and time of the request, in UTC RFC3339 format(for example, YYYY-MM-DDTHH:MM:SSZ) | `string` | `null` | no | +| [spot\_valid\_until](#input\_spot\_valid\_until) | The end date and time of the request, in UTC RFC3339 format(for example, YYYY-MM-DDTHH:MM:SSZ) | `string` | `null` | no | +| [spot\_wait\_for\_fulfillment](#input\_spot\_wait\_for\_fulfillment) | If set, Terraform will wait for the Spot Request to be fulfilled, and will throw an error if the timeout of 10m is reached | `bool` | `null` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [subnet\_id](#input\_subnet\_id) | The VPC Subnet ID to launch in | `string` | `null` | no | +| [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 | +| [tenancy](#input\_tenancy) | The tenancy of the instance (if the instance is running in a VPC). Available values: default, dedicated, host | `string` | `null` | 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 | +| [timeouts](#input\_timeouts) | Define maximum timeout for creating, updating, and deleting EC2 instance resources |
object({
create = optional(string)
update = optional(string)
delete = optional(string)
})
| `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [user\_data](#input\_user\_data) | The user data to provide when launching the instance. Do not pass gzip-compressed data via this argument | `string` | `null` | no | +| [user\_data\_base64](#input\_user\_data\_base64) | Can be used instead of user\_data to pass base64-encoded binary data directly. Use this instead of user\_data whenever the value is not a valid UTF-8 string. For example, gzip-encoded user data must be base64-encoded and passed via this argument to avoid corruption | `string` | `null` | no | +| [user\_data\_replace\_on\_change](#input\_user\_data\_replace\_on\_change) | When used in combination with user\_data or user\_data\_base64 will trigger a destroy and recreate when set to true. Defaults to false if not set | `bool` | `null` | no | +| [volume\_tags](#input\_volume\_tags) | A mapping of tags to assign to the devices created by the instance at launch time | `map(string)` | `{}` | no | +| [vpc\_security\_group\_ids](#input\_vpc\_security\_group\_ids) | List of VPC Security Group IDs to associate with | `list(string)` | `[]` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [ami](#output\_ami) | AMI ID that was used to create the instance | +| [availability\_zone](#output\_availability\_zone) | The availability zone of the created instance | +| [capacity\_reservation\_specification](#output\_capacity\_reservation\_specification) | Capacity reservation specification of the instance | +| [ebs\_block\_device](#output\_ebs\_block\_device) | EBS block device information | +| [ebs\_volumes](#output\_ebs\_volumes) | Map of EBS volumes created and their attributes | +| [ec2\_instance\_arn](#output\_ec2\_instance\_arn) | The ARN of the EC2 instance | +| [ec2\_instance\_id](#output\_ec2\_instance\_id) | The ID of the EC2 instance | +| [ephemeral\_block\_device](#output\_ephemeral\_block\_device) | Ephemeral block device information | +| [iam\_instance\_profile\_arn](#output\_iam\_instance\_profile\_arn) | ARN assigned by AWS to the instance profile | +| [iam\_instance\_profile\_id](#output\_iam\_instance\_profile\_id) | Instance profile's ID | +| [iam\_instance\_profile\_unique](#output\_iam\_instance\_profile\_unique) | Stable and unique string identifying the IAM instance profile | +| [iam\_role\_arn](#output\_iam\_role\_arn) | The ARN specifying the IAM role | +| [iam\_role\_name](#output\_iam\_role\_name) | The name of the IAM role | +| [iam\_role\_unique\_id](#output\_iam\_role\_unique\_id) | Stable and unique string identifying the IAM role | +| [instance\_state](#output\_instance\_state) | The state of the instance | +| [ipv6\_addresses](#output\_ipv6\_addresses) | The IPv6 address assigned to the instance, if applicable | +| [outpost\_arn](#output\_outpost\_arn) | The ARN of the Outpost the instance is assigned to | +| [password\_data](#output\_password\_data) | Base-64 encoded encrypted password data for the instance. Useful for getting the administrator password for instances running Microsoft Windows. This attribute is only exported if `get_password_data` is true | +| [primary\_network\_interface\_id](#output\_primary\_network\_interface\_id) | The ID of the instance's primary network interface | +| [private\_dns](#output\_private\_dns) | The private DNS name assigned to the instance. Can only be used inside the Amazon EC2, and only available if you've enabled DNS hostnames for your VPC | +| [private\_ip](#output\_private\_ip) | The private IP address assigned to the instance | +| [public\_dns](#output\_public\_dns) | The public DNS name assigned to the instance. For EC2-VPC, this is only available if you've enabled DNS hostnames for your VPC | +| [public\_ip](#output\_public\_ip) | The public IP address assigned to the instance, if applicable. | +| [root\_block\_device](#output\_root\_block\_device) | Root block device information | +| [security\_group\_arn](#output\_security\_group\_arn) | The ARN of the security group | +| [security\_group\_id](#output\_security\_group\_id) | The ID of the security group | +| [spot\_bid\_status](#output\_spot\_bid\_status) | The current bid status of the Spot Instance Request | +| [spot\_instance\_id](#output\_spot\_instance\_id) | The Instance ID (if any) that is currently fulfilling the Spot Instance request | +| [spot\_request\_state](#output\_spot\_request\_state) | The current request state of the Spot Instance Request | +| [tags\_all](#output\_tags\_all) | A map of tags assigned to the resource, including those inherited from the provider default\_tags configuration block | + + + diff --git a/infrastructure/modules/ec2-instance/context.tf b/infrastructure/modules/ec2-instance/context.tf new file mode 100644 index 00000000..9a618553 --- /dev/null +++ b/infrastructure/modules/ec2-instance/context.tf @@ -0,0 +1,375 @@ +# 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" { + source = "../tags" + + 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 = {} + # 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. + 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 +} + +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 diff --git a/infrastructure/modules/ec2-instance/main.tf b/infrastructure/modules/ec2-instance/main.tf new file mode 100644 index 00000000..8b0f48e2 --- /dev/null +++ b/infrastructure/modules/ec2-instance/main.tf @@ -0,0 +1,88 @@ +module "ec2_instance" { + source = "terraform-aws-modules/ec2-instance/aws" + version = "6.4.0" + + create = module.this.enabled + name = module.this.id + tags = module.this.tags + + ami = var.ami + ami_ssm_parameter = var.ami_ssm_parameter + associate_public_ip_address = var.associate_public_ip_address + availability_zone = var.availability_zone + capacity_reservation_specification = var.capacity_reservation_specification + cpu_credits = var.cpu_credits + cpu_options = var.cpu_options + create_eip = var.create_eip + create_iam_instance_profile = var.create_iam_instance_profile + create_security_group = var.create_security_group + create_spot_instance = var.create_spot_instance + disable_api_stop = var.disable_api_stop + disable_api_termination = var.disable_api_termination + ebs_optimized = var.ebs_optimized + ebs_volumes = var.ebs_volumes + eip_domain = var.eip_domain + eip_tags = var.eip_tags + enable_primary_ipv6 = var.enable_primary_ipv6 + enable_volume_tags = var.enable_volume_tags + enclave_options_enabled = var.enclave_options_enabled + ephemeral_block_device = var.ephemeral_block_device + force_destroy = var.force_destroy + get_password_data = var.get_password_data + hibernation = var.hibernation + host_id = var.host_id + host_resource_group_arn = var.host_resource_group_arn + iam_instance_profile = var.iam_instance_profile + iam_role_description = var.iam_role_description + iam_role_name = var.iam_role_name + iam_role_path = var.iam_role_path + iam_role_permissions_boundary = var.iam_role_permissions_boundary + iam_role_policies = var.iam_role_policies + iam_role_tags = var.iam_role_tags + iam_role_use_name_prefix = var.iam_role_use_name_prefix + ignore_ami_changes = var.ignore_ami_changes + instance_initiated_shutdown_behavior = var.instance_initiated_shutdown_behavior + instance_market_options = var.instance_market_options + instance_tags = var.instance_tags + instance_type = var.instance_type + ipv6_address_count = var.ipv6_address_count + ipv6_addresses = var.ipv6_addresses + key_name = var.key_name + launch_template = var.launch_template + maintenance_options = var.maintenance_options + metadata_options = var.metadata_options + monitoring = var.monitoring + network_interface = var.network_interface + placement_group = var.placement_group + placement_group_id = var.placement_group_id + placement_partition_number = var.placement_partition_number + private_dns_name_options = var.private_dns_name_options + private_ip = var.private_ip + region = var.aws_region + root_block_device = var.root_block_device + secondary_network_interface = var.secondary_network_interface + secondary_private_ips = var.secondary_private_ips + security_group_description = var.security_group_description + security_group_egress_rules = var.security_group_egress_rules + security_group_ingress_rules = var.security_group_ingress_rules + security_group_name = var.security_group_name + security_group_tags = var.security_group_tags + security_group_use_name_prefix = var.security_group_use_name_prefix + security_group_vpc_id = var.security_group_vpc_id + source_dest_check = var.source_dest_check + spot_instance_interruption_behavior = var.spot_instance_interruption_behavior + spot_price = var.spot_price + spot_type = var.spot_type + spot_valid_from = var.spot_valid_from + spot_valid_until = var.spot_valid_until + spot_wait_for_fulfillment = var.spot_wait_for_fulfillment + subnet_id = var.subnet_id + spot_launch_group = var.spot_launch_group + tenancy = var.tenancy + timeouts = var.timeouts + user_data = var.user_data + user_data_base64 = var.user_data_base64 + user_data_replace_on_change = var.user_data_replace_on_change + volume_tags = var.volume_tags + vpc_security_group_ids = var.vpc_security_group_ids +} diff --git a/infrastructure/modules/ec2-instance/outputs.tf b/infrastructure/modules/ec2-instance/outputs.tf new file mode 100644 index 00000000..4b1c60a0 --- /dev/null +++ b/infrastructure/modules/ec2-instance/outputs.tf @@ -0,0 +1,149 @@ +output "ami" { + description = "AMI ID that was used to create the instance" + value = module.ec2_instance.ami +} + +output "ec2_instance_arn" { + description = "The ARN of the EC2 instance" + value = module.ec2_instance.arn +} + +output "availability_zone" { + description = "The availability zone of the created instance" + value = module.ec2_instance.availability_zone +} + +output "capacity_reservation_specification" { + description = "Capacity reservation specification of the instance" + value = module.ec2_instance.capacity_reservation_specification +} + +output "ebs_block_device" { + description = "EBS block device information" + value = module.ec2_instance.ebs_block_device +} + +output "ebs_volumes" { + description = "Map of EBS volumes created and their attributes" + value = module.ec2_instance.ebs_volumes +} + +output "ephemeral_block_device" { + description = "Ephemeral block device information" + value = module.ec2_instance.ephemeral_block_device +} + +output "iam_instance_profile_arn" { + description = "ARN assigned by AWS to the instance profile" + value = module.ec2_instance.iam_instance_profile_arn +} + +output "iam_instance_profile_id" { + description = "Instance profile's ID" + value = module.ec2_instance.iam_instance_profile_id +} + +output "iam_instance_profile_unique" { + description = "Stable and unique string identifying the IAM instance profile" + value = module.ec2_instance.iam_instance_profile_unique +} + +output "iam_role_arn" { + description = "The ARN specifying the IAM role" + value = module.ec2_instance.iam_role_arn +} + +output "iam_role_name" { + description = "The name of the IAM role" + value = module.ec2_instance.iam_role_name +} + +output "iam_role_unique_id" { + description = "Stable and unique string identifying the IAM role" + value = module.ec2_instance.iam_role_unique_id +} + +output "ec2_instance_id" { + description = "The ID of the EC2 instance" + value = module.ec2_instance.id +} + +output "instance_state" { + description = "The state of the instance" + value = module.ec2_instance.instance_state +} + +output "ipv6_addresses" { + description = "The IPv6 address assigned to the instance, if applicable" + value = module.ec2_instance.ipv6_addresses +} + +output "outpost_arn" { + description = "The ARN of the Outpost the instance is assigned to" + value = module.ec2_instance.outpost_arn +} + +output "password_data" { + description = "Base-64 encoded encrypted password data for the instance. Useful for getting the administrator password for instances running Microsoft Windows. This attribute is only exported if `get_password_data` is true" + value = module.ec2_instance.password_data +} + +output "primary_network_interface_id" { + description = "The ID of the instance's primary network interface" + value = module.ec2_instance.primary_network_interface_id +} + +output "private_dns" { + description = "The private DNS name assigned to the instance. Can only be used inside the Amazon EC2, and only available if you've enabled DNS hostnames for your VPC" + value = module.ec2_instance.private_dns +} + +output "private_ip" { + description = "The private IP address assigned to the instance" + value = module.ec2_instance.private_ip +} + +output "public_dns" { + description = "The public DNS name assigned to the instance. For EC2-VPC, this is only available if you've enabled DNS hostnames for your VPC" + value = module.ec2_instance.public_dns +} + +output "public_ip" { + description = "The public IP address assigned to the instance, if applicable." + value = module.ec2_instance.public_ip +} + +output "root_block_device" { + description = "Root block device information" + value = module.ec2_instance.root_block_device +} + +output "security_group_arn" { + description = "The ARN of the security group" + value = module.ec2_instance.security_group_arn +} + +output "security_group_id" { + description = "The ID of the security group" + value = module.ec2_instance.security_group_id +} + +output "spot_bid_status" { + description = "The current bid status of the Spot Instance Request" + value = module.ec2_instance.spot_bid_status +} + +output "spot_instance_id" { + description = "The Instance ID (if any) that is currently fulfilling the Spot Instance request" + value = module.ec2_instance.spot_instance_id +} + +output "spot_request_state" { + description = "The current request state of the Spot Instance Request" + value = module.ec2_instance.spot_request_state +} + +output "tags_all" { + description = "A map of tags assigned to the resource, including those inherited from the provider default_tags configuration block" + value = module.ec2_instance.tags_all +} diff --git a/infrastructure/modules/ec2-instance/variables.tf b/infrastructure/modules/ec2-instance/variables.tf new file mode 100644 index 00000000..6202002c --- /dev/null +++ b/infrastructure/modules/ec2-instance/variables.tf @@ -0,0 +1,588 @@ +################################################################ +# EC2 instance-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# context.tf via `module.this`. +################################################################ + +variable "ami" { + description = "ID of AMI to use for the instance" + type = string + default = null +} + +variable "ami_ssm_parameter" { + description = "SSM parameter name for the AMI ID. For Amazon Linux AMI SSM parameters see https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-public-parameters-ami.html" + type = string + default = "/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64" +} + +variable "associate_public_ip_address" { + description = "Whether to associate a public IP address with an instance in a VPC" + type = bool + default = null +} + +variable "availability_zone" { + description = "The AZ to start the instance in" + type = string + default = null +} + +variable "capacity_reservation_specification" { + description = "Describes an instance's Capacity Reservation targeting option" + type = object({ + capacity_reservation_preference = optional(string) + capacity_reservation_target = optional(object({ + capacity_reservation_id = optional(string) + capacity_reservation_resource_group_arn = optional(string) + })) + }) + default = null +} + +variable "cpu_credits" { + description = "The credit option for CPU usage (unlimited or standard)" + type = string + default = null +} + +variable "cpu_options" { + description = "Defines CPU options to apply to the instance at launch time." + type = object({ + amd_sev_snp = optional(string) + core_count = optional(number) + nested_virtualization = optional(string) + threads_per_core = optional(number) + }) + default = null +} + +variable "create_eip" { + description = "Determines whether a public EIP will be created and associated with the instance." + type = bool + default = false +} + +variable "create_iam_instance_profile" { + description = "Determines whether an IAM instance profile is created or to use an existing IAM instance profile" + type = bool + default = false +} + +variable "create_security_group" { + description = "Determines whether a security group will be created" + type = bool + default = true +} + +variable "create_spot_instance" { + description = "Depicts if the instance is a spot instance" + type = bool + default = false +} + +variable "disable_api_stop" { + description = "If true, enables EC2 Instance Stop Protection" + type = bool + default = null +} + +variable "disable_api_termination" { + description = "If true, enables EC2 Instance Termination Protection" + type = bool + default = null +} + +variable "ebs_optimized" { + description = "If true, the launched EC2 instance will be EBS-optimized" + type = bool + default = null +} + +variable "ebs_volumes" { + description = "Map of EBS volumes to attach to the instance" + type = map(object({ + encrypted = optional(bool) + final_snapshot = optional(bool) + iops = optional(number) + kms_key_id = optional(string) + multi_attach_enabled = optional(bool) + outpost_arn = optional(string) + size = optional(number) + snapshot_id = optional(string) + tags = optional(map(string), {}) + throughput = optional(number) + type = optional(string, "gp3") + volume_initialization_rate = optional(number) + # Attachment + device_name = optional(string) # Will fall back to use map key as device name + force_detach = optional(bool) + skip_destroy = optional(bool) + stop_instance_before_detaching = optional(bool) + })) + default = null +} + +variable "eip_domain" { + description = "Indicates if this EIP is for use in VPC" + type = string + default = "vpc" +} + +variable "eip_tags" { + description = "A map of additional tags to add to the EIP" + type = map(string) + default = {} +} + +variable "enable_primary_ipv6" { + description = "Whether to assign a primary IPv6 Global Unicast Address (GUA) to the instance when launched in a dual-stack or IPv6-only subnet" + type = bool + default = null +} + +variable "enable_volume_tags" { + description = "Whether to enable volume tags (if enabled it conflicts with root_block_device tags)" + type = bool + default = true +} + +variable "enclave_options_enabled" { + description = "Whether Nitro Enclaves will be enabled on the instance. Defaults to `false`" + type = bool + default = null +} + +variable "ephemeral_block_device" { + description = "Customize Ephemeral (also known as Instance Store) volumes on the instance" + type = map(object({ + device_name = optional(string) + no_device = optional(bool) + virtual_name = optional(string) + })) + default = null +} + +variable "force_destroy" { + description = "Destroys instance even if `disable_api_termination` or `disable_api_stop` is set to true. Once this parameter is set to true, a successful terraform apply run before a destroy is required to update this value in the resource state. Without a successful terraform apply after this parameter is set, this flag will have no effect. If setting this field in the same operation that would require replacing the instance or destroying the instance, this flag will not work. Additionally when importing an instance, a successful terraform apply is required to set this value in state before it will take effect on a destroy operation." + type = bool + default = null +} + +variable "get_password_data" { + description = "If true, wait for password data to become available and retrieve it" + type = bool + default = null +} + +variable "hibernation" { + description = "If true, the launched EC2 instance will support hibernation" + type = bool + default = null +} + +variable "host_id" { + description = "ID of a dedicated host that the instance will be assigned to. Use when an instance is to be launched on a specific dedicated host" + type = string + default = null +} + +variable "host_resource_group_arn" { + description = "ARN of the host resource group in which to launch the instances. If you specify an ARN, omit the `tenancy` parameter or set it to `host`" + type = string + default = null +} + +variable "iam_instance_profile" { + description = "IAM Instance Profile to launch the instance with. Specified as the name of the Instance Profile" + type = string + default = null +} + +variable "iam_role_description" { + description = "Description of the role" + type = string + default = null +} + +variable "iam_role_name" { + description = "Name to use on IAM role created" + type = string + default = null +} + +variable "iam_role_path" { + description = "IAM role path" + type = string + default = null +} + +variable "iam_role_permissions_boundary" { + description = "ARN of the policy that is used to set the permissions boundary for the IAM role" + type = string + default = null +} + +variable "iam_role_policies" { + description = "Policies attached to the IAM role" + type = map(string) + default = {} +} + +variable "iam_role_tags" { + description = "A map of additional tags to add to the IAM role/profile created" + type = map(string) + default = {} +} + +variable "iam_role_use_name_prefix" { + description = "Determines whether the IAM role name (`iam_role_name` or `name`) is used as a prefix" + type = bool + default = true +} + +variable "ignore_ami_changes" { + description = "Whether changes to the AMI ID changes should be ignored by Terraform. Note - changing this value will result in the replacement of the instance" + type = bool + default = false +} + +variable "instance_initiated_shutdown_behavior" { + description = "Shutdown behavior for the instance. Amazon defaults this to stop for EBS-backed instances and terminate for instance-store instances. Cannot be set on instance-store instance" + type = string + default = null +} + +variable "instance_market_options" { + description = "The market (purchasing) option for the instance. If set, overrides the `create_spot_instance` variable" + type = object({ + market_type = optional(string) + spot_options = optional(object({ + instance_interruption_behavior = optional(string) + max_price = optional(string) + spot_instance_type = optional(string) + valid_until = optional(string) + })) + }) + default = null +} + +variable "instance_tags" { + description = "Additional tags for the instance" + type = map(string) + default = {} +} + +variable "instance_type" { + description = "The type of instance to start" + type = string + default = "t3.micro" +} + +variable "ipv6_address_count" { + description = "A number of IPv6 addresses to associate with the primary network interface. Amazon EC2 chooses the IPv6 addresses from the range of your subnet" + type = number + default = null +} + +variable "ipv6_addresses" { + description = "Specify one or more IPv6 addresses from the range of the subnet to associate with the primary network interface" + type = list(string) + default = null +} + +variable "key_name" { + description = "Name of the Key Pair to use for the instance; which can be managed using the `aws_key_pair` resource" + type = string + default = null +} + +variable "launch_template" { + description = "Specifies a Launch Template to configure the instance. Parameters configured on this resource will override the corresponding parameters in the Launch Template" + type = object({ + id = optional(string) + name = optional(string) + version = optional(string) + }) + default = null +} + +variable "maintenance_options" { + description = "The maintenance options for the instance" + type = object({ + auto_recovery = optional(string) + }) + default = null +} + +variable "metadata_options" { + description = "Customize the metadata options of the instance" + type = object({ + http_endpoint = optional(string, "enabled") + http_protocol_ipv6 = optional(string) + http_put_response_hop_limit = optional(number, 1) + http_tokens = optional(string, "required") + instance_metadata_tags = optional(string) + }) + default = {} +} + +variable "monitoring" { + description = "If true, the launched EC2 instance will have detailed monitoring enabled" + type = bool + default = null +} + +variable "network_interface" { + description = "Customize network interfaces to be attached at instance boot time" + type = map(object({ + delete_on_termination = optional(bool) + device_index = optional(number) # Will fall back to use map key as device index + network_card_index = optional(number) + network_interface_id = string + })) + default = null +} + +variable "placement_group" { + description = "The Placement Group to start the instance in" + type = string + default = null +} + +variable "placement_group_id" { + description = "Placement Group ID to start the instance in" + type = string + default = null +} + +variable "placement_partition_number" { + description = "Number of the partition the instance is in. Valid only if the `aws_placement_group` resource's `strategy` argument is set to `partition`" + type = number + default = null +} + +variable "private_dns_name_options" { + description = "Customize the private DNS name options of the instance" + type = object({ + enable_resource_name_dns_aaaa_record = optional(bool) + enable_resource_name_dns_a_record = optional(bool) + hostname_type = optional(string) + }) + default = null +} + +variable "private_ip" { + description = "The private IP address to associate with the instance in a VPC" + type = string + default = null +} + +variable "root_block_device" { + description = "Customize details about the root block device of the instance" + type = object({ + delete_on_termination = optional(bool) + encrypted = optional(bool) + iops = optional(number) + kms_key_id = optional(string) + tags = optional(map(string)) + throughput = optional(number) + size = optional(number) + type = optional(string) + }) + default = null +} + +variable "secondary_network_interface" { + description = "Customize secondary network interfaces to be attached to the EC2 instance" + type = map(object({ + delete_on_termination = optional(bool) + device_index = optional(number) # Will fall back to use map key as device index + interface_type = optional(string) + network_card_index = number + private_ip_address_count = optional(number) + private_ip_addresses = optional(list(string)) + secondary_subnet_id = string + })) + default = null +} + +variable "secondary_private_ips" { + description = "A list of secondary private IPv4 addresses to assign to the instance's primary network interface (eth0) in a VPC. Can only be assigned to the primary network interface (eth0) attached at instance creation, not a pre-existing network interface i.e. referenced in a `network_interface block`" + type = list(string) + default = null +} + +variable "security_group_description" { + description = "Description of the security group" + type = string + default = null +} + +variable "security_group_egress_rules" { + description = "Egress rules to add to the security group" + type = map(object({ + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + from_port = optional(number) + ip_protocol = optional(string, "tcp") + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + to_port = optional(number) + })) + default = { + "ipv4_default" : { + "cidr_ipv4" : "0.0.0.0/0", + "description" : "Allow all IPv4 traffic", + "ip_protocol" : "-1" + }, + "ipv6_default" : { + "cidr_ipv6" : "::/0", + "description" : "Allow all IPv6 traffic", + "ip_protocol" : "-1" + } + } +} + +variable "security_group_ingress_rules" { + description = "Ingress rules to add to the security group" + type = map(object({ + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + from_port = optional(number) + ip_protocol = optional(string, "tcp") + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + to_port = optional(number) + })) + default = null +} + +variable "security_group_name" { + description = "Name to use on security group created" + type = string + default = null +} + +variable "security_group_tags" { + description = "A map of additional tags to add to the security group created" + type = map(string) + default = {} +} + +variable "security_group_use_name_prefix" { + description = "Determines whether the security group name (`security_group_name` or `name`) is used as a prefix" + type = bool + default = true +} + +variable "security_group_vpc_id" { + description = "VPC ID to create the security group in. If not set, the security group will be created in the default VPC" + type = string + default = null +} + +variable "source_dest_check" { + description = "Controls if traffic is routed to the instance when the destination address does not match the instance. Used for NAT or VPNs" + type = bool + default = null +} + +variable "spot_instance_interruption_behavior" { + description = "Indicates Spot instance behavior when it is interrupted. Valid values are `terminate`, `stop`, or `hibernate`" + type = string + default = null +} + +variable "spot_launch_group" { + description = "A launch group is a group of spot instances that launch together and terminate together. If left empty instances are launched and terminated individually" + type = string + default = null +} + +variable "spot_price" { + description = "The maximum price to request on the spot market. Defaults to on-demand price" + type = string + default = null +} + +variable "spot_type" { + description = "If set to one-time, after the instance is terminated, the spot request will be closed. Default `persistent`" + type = string + default = null +} + +variable "spot_valid_from" { + description = "The start date and time of the request, in UTC RFC3339 format(for example, YYYY-MM-DDTHH:MM:SSZ)" + type = string + default = null +} + +variable "spot_valid_until" { + description = "The end date and time of the request, in UTC RFC3339 format(for example, YYYY-MM-DDTHH:MM:SSZ)" + type = string + default = null +} + +variable "spot_wait_for_fulfillment" { + description = "If set, Terraform will wait for the Spot Request to be fulfilled, and will throw an error if the timeout of 10m is reached" + type = bool + default = null +} + +variable "subnet_id" { + description = "The VPC Subnet ID to launch in" + type = string + default = null +} + +variable "tenancy" { + description = "The tenancy of the instance (if the instance is running in a VPC). Available values: default, dedicated, host" + type = string + default = null +} + +variable "timeouts" { + description = "Define maximum timeout for creating, updating, and deleting EC2 instance resources" + type = object({ + create = optional(string) + update = optional(string) + delete = optional(string) + }) + default = null +} + +variable "user_data" { + description = "The user data to provide when launching the instance. Do not pass gzip-compressed data via this argument" + type = string + default = null +} + +variable "user_data_base64" { + description = "Can be used instead of user_data to pass base64-encoded binary data directly. Use this instead of user_data whenever the value is not a valid UTF-8 string. For example, gzip-encoded user data must be base64-encoded and passed via this argument to avoid corruption" + type = string + default = null +} + +variable "user_data_replace_on_change" { + description = "When used in combination with user_data or user_data_base64 will trigger a destroy and recreate when set to true. Defaults to false if not set" + type = bool + default = null +} + +variable "volume_tags" { + description = "A mapping of tags to assign to the devices created by the instance at launch time" + type = map(string) + default = {} +} + +variable "vpc_security_group_ids" { + description = "List of VPC Security Group IDs to associate with" + type = list(string) + default = [] +} diff --git a/infrastructure/modules/ec2-instance/versions.tf b/infrastructure/modules/ec2-instance/versions.tf new file mode 100644 index 00000000..cb30fe5c --- /dev/null +++ b/infrastructure/modules/ec2-instance/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.42" + } + } +} From 25dff5274cbf270c7d036ce454312dac5034bc05 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Fri, 3 Jul 2026 13:41:23 +0100 Subject: [PATCH 086/118] feat(cloudwatch): check-in for dev's review comments - refine shared CloudWatch module interfaces and validations --- .../cloudwatch-log-metric-filter/main.tf | 7 ------ .../cloudwatch-log-metric-filter/variables.tf | 2 +- .../modules/cloudwatch-logs/README.md | 17 ++++++-------- .../modules/cloudwatch-logs/locals.tf | 3 +++ .../modules/cloudwatch-logs/main.tf | 11 ++-------- .../modules/cloudwatch-logs/variables.tf | 19 ++++++++-------- .../cloudwatch-metric-alarm/variables.tf | 22 +++++++++++++++++++ 7 files changed, 44 insertions(+), 37 deletions(-) diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/main.tf b/infrastructure/modules/cloudwatch-log-metric-filter/main.tf index d51d1b89..ba67029c 100644 --- a/infrastructure/modules/cloudwatch-log-metric-filter/main.tf +++ b/infrastructure/modules/cloudwatch-log-metric-filter/main.tf @@ -23,10 +23,3 @@ module "log_metric_filter" { metric_transformation_name = var.metric_transformation_name metric_transformation_namespace = local.metric_namespace } - -check "log_group_must_exist" { - assert { - condition = var.log_group_name != "" - error_message = "log_group_name is required and must not be empty." - } -} diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/variables.tf b/infrastructure/modules/cloudwatch-log-metric-filter/variables.tf index a35f95ce..27d5ca11 100644 --- a/infrastructure/modules/cloudwatch-log-metric-filter/variables.tf +++ b/infrastructure/modules/cloudwatch-log-metric-filter/variables.tf @@ -17,7 +17,7 @@ variable "log_group_name" { variable "pattern" { type = string default = "" - description = "Log pattern to filter on (e.g., 'ERROR', '[ERROR]', etc). Empty string matches all events." + description = "CloudWatch Logs filter pattern syntax used to match log events (for example a plain term like 'ERROR' or a structured pattern like '[level = \"ERROR\"]'). This is AWS filter pattern syntax, not a regular expression. Empty string matches all events." } variable "metric_transformation_name" { diff --git a/infrastructure/modules/cloudwatch-logs/README.md b/infrastructure/modules/cloudwatch-logs/README.md index fe1d9385..ef56fca4 100644 --- a/infrastructure/modules/cloudwatch-logs/README.md +++ b/infrastructure/modules/cloudwatch-logs/README.md @@ -9,8 +9,8 @@ NHS Screening wrapper around the [terraform-aws-modules/CloudWatch/aws](https:// | Naming | Log group and stream names are derived from `module.this.id` via context | | Tagging | All NHS-required tags applied via `module.this.tags` | | Retention | Configurable retention in days; defaults to 7 days | -| Encryption | Optional KMS key support; AWS-managed by default | -| Creation gate | Both log group and stream gated via `module.this.enabled` | +| Encryption | Encryption at rest is always enabled; AWS-managed by default, or customer-managed KMS when `kms_key_id` is set | +| Creation gate | Resource creation mode is explicit and gated via `module.this.enabled` | ## Usage @@ -24,8 +24,7 @@ module "app_logs" { stack = "app" name = "ecs" - create_log_group = true - create_log_stream = false + create = "LOG_GROUP_ONLY" retention_in_days = 30 } ``` @@ -40,8 +39,7 @@ module "lambda_logs" { stack = "workers" name = "background-jobs" - create_log_group = true - create_log_stream = true + create = "LOG_GROUP_AND_LOG_STREAM" retention_in_days = 90 kms_key_id = module.kms.key_arn } @@ -59,8 +57,7 @@ module "logs" { source = "../../modules/cloudwatch-logs" context = module.this.context - create_log_group = true - create_log_stream = false + create = "LOG_GROUP_ONLY" } module "error_filter" { @@ -78,7 +75,7 @@ module "error_filter" { ## Conventions - Log group and stream names are derived from `module.this.id` (e.g., `service-environment-stack-name`). -- `create_log_stream = true` requires `create_log_group = true`; the check block enforces this. +- `create` controls whether the module creates nothing, a log group only, or both a log group and a log stream. - Log group retention defaults to 7 days; adjust via `retention_in_days`. - When both are created, the stream is automatically associated with the group. @@ -126,7 +123,7 @@ No resources. | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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\_id](#input\_kms\_key\_id) | ARN of KMS key for log group encryption. When null, uses AWS-managed encryption. | `string` | `null` | no | +| [kms\_key\_id](#input\_kms\_key\_id) | Optional customer-managed KMS key ARN for CloudWatch log group encryption. When null, CloudWatch Logs uses AWS-managed encryption. Encryption at rest remains enabled either way. | `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 | diff --git a/infrastructure/modules/cloudwatch-logs/locals.tf b/infrastructure/modules/cloudwatch-logs/locals.tf index c8c0db4c..38c2a9df 100644 --- a/infrastructure/modules/cloudwatch-logs/locals.tf +++ b/infrastructure/modules/cloudwatch-logs/locals.tf @@ -1,4 +1,7 @@ locals { + create_log_group = var.create == "LOG_GROUP_ONLY" || var.create == "LOG_GROUP_AND_LOG_STREAM" + create_log_stream = var.create == "LOG_GROUP_AND_LOG_STREAM" + log_group_name = module.this.id log_stream_name = format("%s-stream", module.this.id) } diff --git a/infrastructure/modules/cloudwatch-logs/main.tf b/infrastructure/modules/cloudwatch-logs/main.tf index 38b6d8ea..6904ee5c 100644 --- a/infrastructure/modules/cloudwatch-logs/main.tf +++ b/infrastructure/modules/cloudwatch-logs/main.tf @@ -16,7 +16,7 @@ module "log_group" { source = "terraform-aws-modules/cloudwatch/aws//modules/log-group" version = "5.7.2" - create = module.this.enabled && var.create_log_group + create = module.this.enabled && local.create_log_group name = local.log_group_name retention_in_days = var.retention_in_days @@ -29,15 +29,8 @@ module "log_stream" { source = "terraform-aws-modules/cloudwatch/aws//modules/log-stream" version = "5.7.2" - create = module.this.enabled && var.create_log_stream && var.create_log_group + create = module.this.enabled && local.create_log_stream name = local.log_stream_name log_group_name = module.log_group.cloudwatch_log_group_name } - -check "log_stream_requires_log_group" { - assert { - condition = !var.create_log_stream || var.create_log_group - error_message = "create_log_stream requires create_log_group = true" - } -} diff --git a/infrastructure/modules/cloudwatch-logs/variables.tf b/infrastructure/modules/cloudwatch-logs/variables.tf index 0bbd103e..4b2725c9 100644 --- a/infrastructure/modules/cloudwatch-logs/variables.tf +++ b/infrastructure/modules/cloudwatch-logs/variables.tf @@ -5,16 +5,15 @@ # context.tf via `module.this`. ################################################################ -variable "create_log_group" { - type = bool - default = true - description = "Whether to create the CloudWatch log group." -} +variable "create" { + type = string + default = "LOG_GROUP_ONLY" + description = "Creation mode for CloudWatch log resources. Valid values are NOTHING, LOG_GROUP_ONLY, and LOG_GROUP_AND_LOG_STREAM." -variable "create_log_stream" { - type = bool - default = false - description = "Whether to create a CloudWatch log stream. Requires create_log_group = true." + validation { + condition = contains(["NOTHING", "LOG_GROUP_ONLY", "LOG_GROUP_AND_LOG_STREAM"], var.create) + error_message = "create must be one of: NOTHING, LOG_GROUP_ONLY, LOG_GROUP_AND_LOG_STREAM." + } } variable "retention_in_days" { @@ -31,5 +30,5 @@ variable "retention_in_days" { variable "kms_key_id" { type = string default = null - description = "ARN of KMS key for log group encryption. When null, uses AWS-managed encryption." + description = "Optional customer-managed KMS key ARN for CloudWatch log group encryption. When null, CloudWatch Logs uses AWS-managed encryption. Encryption at rest remains enabled either way." } diff --git a/infrastructure/modules/cloudwatch-metric-alarm/variables.tf b/infrastructure/modules/cloudwatch-metric-alarm/variables.tf index fbd76c3c..3bb2285d 100644 --- a/infrastructure/modules/cloudwatch-metric-alarm/variables.tf +++ b/infrastructure/modules/cloudwatch-metric-alarm/variables.tf @@ -17,6 +17,17 @@ variable "metric_alarm" { }) default = null description = "Configuration for a single metric alarm. Set to null to skip creation." + + validation { + condition = var.metric_alarm == null ? true : contains([ + "SampleCount", + "Average", + "Sum", + "Minimum", + "Maximum" + ], var.metric_alarm.statistic) + error_message = "metric_alarm.statistic must be one of: SampleCount, Average, Sum, Minimum, Maximum." + } } variable "metric_alarms_by_multiple_dimensions" { @@ -33,6 +44,17 @@ variable "metric_alarms_by_multiple_dimensions" { }) default = null description = "Configuration for metric alarms by multiple dimensions (creates one alarm per dimension combo). Set to null to skip creation." + + validation { + condition = var.metric_alarms_by_multiple_dimensions == null ? true : contains([ + "SampleCount", + "Average", + "Sum", + "Minimum", + "Maximum" + ], var.metric_alarms_by_multiple_dimensions.statistic) + error_message = "metric_alarms_by_multiple_dimensions.statistic must be one of: SampleCount, Average, Sum, Minimum, Maximum." + } } variable "alarm_actions" { From 437caedd1c9a7c2a82480ff8f09bf4be9678b72a Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Fri, 3 Jul 2026 13:47:56 +0100 Subject: [PATCH 087/118] fix(cloudwatch-logs): update outputs for create mode refactor --- infrastructure/modules/cloudwatch-logs/outputs.tf | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/infrastructure/modules/cloudwatch-logs/outputs.tf b/infrastructure/modules/cloudwatch-logs/outputs.tf index 484169cd..dc6dbbc6 100644 --- a/infrastructure/modules/cloudwatch-logs/outputs.tf +++ b/infrastructure/modules/cloudwatch-logs/outputs.tf @@ -1,19 +1,19 @@ output "cloudwatch_log_group_name" { description = "Name of the CloudWatch log group, if created." - value = var.create_log_group ? module.log_group.cloudwatch_log_group_name : null + value = local.create_log_group ? module.log_group.cloudwatch_log_group_name : null } output "cloudwatch_log_group_arn" { description = "ARN of the CloudWatch log group, if created." - value = var.create_log_group ? module.log_group.cloudwatch_log_group_arn : null + value = local.create_log_group ? module.log_group.cloudwatch_log_group_arn : null } output "cloudwatch_log_stream_name" { description = "Name of the CloudWatch log stream, if created." - value = var.create_log_stream ? module.log_stream.cloudwatch_log_stream_name : null + value = local.create_log_stream ? module.log_stream.cloudwatch_log_stream_name : null } output "cloudwatch_log_stream_arn" { description = "ARN of the CloudWatch log stream, if created." - value = var.create_log_stream ? module.log_stream.cloudwatch_log_stream_arn : null + value = local.create_log_stream ? module.log_stream.cloudwatch_log_stream_arn : null } From 80e1266b63a7e6a25a412b26646b1d276eb54b68 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Fri, 3 Jul 2026 13:53:40 +0100 Subject: [PATCH 088/118] fix(cloudwatch): updated readme --- infrastructure/modules/cloudwatch-log-metric-filter/README.md | 2 +- infrastructure/modules/cloudwatch-logs/README.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/cloudwatch-log-metric-filter/README.md b/infrastructure/modules/cloudwatch-log-metric-filter/README.md index 3d824d96..33685df9 100644 --- a/infrastructure/modules/cloudwatch-log-metric-filter/README.md +++ b/infrastructure/modules/cloudwatch-log-metric-filter/README.md @@ -138,7 +138,7 @@ No resources. | [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 | | [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 | -| [pattern](#input\_pattern) | Log pattern to filter on (e.g., 'ERROR', '[ERROR]', etc). Empty string matches all events. | `string` | `""` | no | +| [pattern](#input\_pattern) | CloudWatch Logs filter pattern syntax used to match log events (for example a plain term like 'ERROR' or a structured pattern like '[level = "ERROR"]'). This is AWS filter pattern syntax, not a regular expression. Empty string matches all events. | `string` | `""` | 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 | diff --git a/infrastructure/modules/cloudwatch-logs/README.md b/infrastructure/modules/cloudwatch-logs/README.md index ef56fca4..4a23b182 100644 --- a/infrastructure/modules/cloudwatch-logs/README.md +++ b/infrastructure/modules/cloudwatch-logs/README.md @@ -114,8 +114,7 @@ No resources. | [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 | | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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\_log\_group](#input\_create\_log\_group) | Whether to create the CloudWatch log group. | `bool` | `true` | no | -| [create\_log\_stream](#input\_create\_log\_stream) | Whether to create a CloudWatch log stream. Requires create\_log\_group = true. | `bool` | `false` | no | +| [create](#input\_create) | Creation mode for CloudWatch log resources. Valid values are NOTHING, LOG\_GROUP\_ONLY, and LOG\_GROUP\_AND\_LOG\_STREAM. | `string` | `"LOG_GROUP_ONLY"` | no | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | From 5f599be06ad8ecdd7287691ce59e325d73395ee1 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Fri, 3 Jul 2026 14:02:50 +0100 Subject: [PATCH 089/118] fix: refresh rds-database provider lock file checksums --- .../modules/rds-database/.terraform.lock.hcl | 38 +++++++++---------- 1 file changed, 17 insertions(+), 21 deletions(-) diff --git a/infrastructure/modules/rds-database/.terraform.lock.hcl b/infrastructure/modules/rds-database/.terraform.lock.hcl index b40b96e5..c3f0a329 100644 --- a/infrastructure/modules/rds-database/.terraform.lock.hcl +++ b/infrastructure/modules/rds-database/.terraform.lock.hcl @@ -28,29 +28,25 @@ provider "registry.terraform.io/cyrilgdn/postgresql" { } provider "registry.terraform.io/hashicorp/aws" { - version = "6.50.0" + version = "6.53.0" constraints = ">= 6.47.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:UFEhEEFJcR/pAOZcwdR11gN9W3X8VwvSl1IS0vbj2G0=", + "zh:0757ce9d5a30e8225521857924f5d6c49e5885fd9e309e56193c9c9920b3f8f0", + "zh:10ddb3a20e0779788e8002bc67d184eb166dec70f9d07220bfc55e15c3e5e206", + "zh:1e943f59a8c3f3b04f09fd5d967fef2518e0ce3ec4959e31f5d7f820660e8524", + "zh:21d55ad3b28f48c6dd34faceeb1fd1fe2ec3b1aaecfc5eb0e0841884f6520d69", + "zh:2e551bc0ff29ea608a99f63d899afbdf2f23c14035f27dc0c0bdca60209d9575", + "zh:581db963564364200426f2a4be866470a975cde6a4bd6fd09362ec2bab119cbb", + "zh:71a7abc88e16ccbe4410fc4966bd35fae9e0161c1385a9b3c19800e3db626b01", + "zh:8767767776c45590d4cc74b4c37f6377e05ce75039a9c2e834de0bbcc29f7a1a", + "zh:99c8e37beb9b1017f67c41e402031559b745fa797848f1a4ef8b40421dee3f05", "zh:9b12af85486a96aedd8d7984b0ff811a4b42e3d88dad1a3fb4c0b580d04fa425", - "zh:b84c87c58a320adbb2c74a4cad03ae5aac7f2eae21db26f00fdde98c8c4d4523", - "zh:c895f1d5cbcbeff77850ac99efd36bde0048d4e909b296882331b9b9ebf48cfa", - "zh:ead82831683619124597a1f170dd31e9b293e9cf22f558cb166d5e734fcd11e4", + "zh:ba1477eda2f3ee51846492449338146f80806088d2952c08c2f8af874550a75d", + "zh:c389f68ba4d39fa2bf8a54d4af7f7b2132da2a964c260892c1dd1a89b67e61f8", + "zh:d7381490a637a1fa45769d0ee6b3e0578cce488d078077ec63d9090113784f2a", + "zh:df3b82fb1a675fc6548fac2fff10a8741efa5bcb5a53d01d8f0659179a2f717f", + "zh:e5d78b2ac3dc5477cf21d7cd4c8b9e3d944ff091e90b62c1b2ea9cc7c7eef57c", + "zh:fd224078287c6d82de2ec30ea03d3d550c2c554e372b234f7a6ac2b07c23dba0", ] } From b7a38494848fe007837610c44142062bc643f676 Mon Sep 17 00:00:00 2001 From: Uzair Haroon Date: Fri, 3 Jul 2026 15:10:46 +0100 Subject: [PATCH 090/118] fix: add multi-platform checksums to rds-database provider lock file --- infrastructure/modules/rds-database/.terraform.lock.hcl | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/infrastructure/modules/rds-database/.terraform.lock.hcl b/infrastructure/modules/rds-database/.terraform.lock.hcl index c3f0a329..c3c51676 100644 --- a/infrastructure/modules/rds-database/.terraform.lock.hcl +++ b/infrastructure/modules/rds-database/.terraform.lock.hcl @@ -32,6 +32,10 @@ provider "registry.terraform.io/hashicorp/aws" { constraints = ">= 6.47.0" hashes = [ "h1:UFEhEEFJcR/pAOZcwdR11gN9W3X8VwvSl1IS0vbj2G0=", + "h1:bvlWCSuVJQshwuA/vJONdjEUIzGdsW3uSfLxoP9RnIw=", + "h1:eD0xCJQCp+iQQKpU/SpMk/pGRrkF16UUJAEMCXvWCWo=", + "h1:nZ85OLLO0sNw/76mgQQQmnOodiQGaaGVxq1NaV3ol4g=", + "h1:sSfqLt0XIbqfTvOSZr2gkyiv1Ysg6uuiVXFd/btmsoY=", "zh:0757ce9d5a30e8225521857924f5d6c49e5885fd9e309e56193c9c9920b3f8f0", "zh:10ddb3a20e0779788e8002bc67d184eb166dec70f9d07220bfc55e15c3e5e206", "zh:1e943f59a8c3f3b04f09fd5d967fef2518e0ce3ec4959e31f5d7f820660e8524", From 455a9eff5702ec04ab826bbe033c9c0de67656d2 Mon Sep 17 00:00:00 2001 From: DeepikaDK Date: Tue, 7 Jul 2026 12:54:16 +0100 Subject: [PATCH 091/118] fix(cognito): correct correct "password" spelling in output name and updated awscc baseline comment --- infrastructure/modules/cognito/README.md | 2 +- infrastructure/modules/cognito/outputs.tf | 2 +- infrastructure/modules/cognito/versions.tf | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/infrastructure/modules/cognito/README.md b/infrastructure/modules/cognito/README.md index 5f93898a..927dd6ef 100644 --- a/infrastructure/modules/cognito/README.md +++ b/infrastructure/modules/cognito/README.md @@ -159,7 +159,7 @@ If those become required later, they can be added back with an explicit shared-r | [client\_ids\_map](#output\_client\_ids\_map) | Map of Cognito client names to client IDs. | | [client\_secrets](#output\_client\_secrets) | Secrets of any Cognito user pool clients created by this module. | | [client\_secrets\_map](#output\_client\_secrets\_map) | Map of Cognito client names to client secrets. | -| [secrets\_manager\_random\_passsword\_arn](#output\_secrets\_manager\_random\_passsword\_arn) | Deprecated compatibility output from the bespoke BS-Select bootstrap-user flow. This wrapper does not create a bootstrap user secret. | +| [secrets\_manager\_random\_password\_arn](#output\_secrets\_manager\_random\_password\_arn) | Deprecated compatibility output from the bespoke BS-Select bootstrap-user flow. This wrapper does not create a bootstrap user secret. | | [user\_pool\_arn](#output\_user\_pool\_arn) | ARN of the Cognito user pool. | | [user\_pool\_domain\_prefix](#output\_user\_pool\_domain\_prefix) | Configured Cognito domain value. | | [user\_pool\_endpoint](#output\_user\_pool\_endpoint) | Cognito user pool endpoint. | diff --git a/infrastructure/modules/cognito/outputs.tf b/infrastructure/modules/cognito/outputs.tf index 3cb1cfb9..638bfc4d 100644 --- a/infrastructure/modules/cognito/outputs.tf +++ b/infrastructure/modules/cognito/outputs.tf @@ -61,7 +61,7 @@ output "app_client_secrets" { sensitive = true } -output "secrets_manager_random_passsword_arn" { +output "secrets_manager_random_password_arn" { description = "Deprecated compatibility output from the bespoke BS-Select bootstrap-user flow. This wrapper does not create a bootstrap user secret." value = null } diff --git a/infrastructure/modules/cognito/versions.tf b/infrastructure/modules/cognito/versions.tf index 9fc50fac..e6bd5b09 100644 --- a/infrastructure/modules/cognito/versions.tf +++ b/infrastructure/modules/cognito/versions.tf @@ -10,7 +10,7 @@ terraform { awscc = { # Not used directly in this module # Used by `lgallard/cognito-user-pool/aws` for managed login branding - # Not clear what the minimum version should be + # Pinned to the NHS platform provider baseline that supports this usage source = "hashicorp/awscc" version = ">= 1.89" } From 41ba13925fa1435395c5ea7265227def27aec6da Mon Sep 17 00:00:00 2001 From: dave4420 Date: Thu, 9 Jul 2026 10:03:31 +0100 Subject: [PATCH 092/118] feat(ssm-parameter): add ssm-parameter module (#82) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat(ssm-parameter): add bare bones module * feat(ssm-parameter): add context * fix(ssm-parameter): handle name correctly (can’t be relative path) * feat(ssm-parameter): add missing input variables * feat(ssm-parameter): add outputs * docs(ssm-parameter): add intro * docs(ssm-parameter): add usage section * feat(gitleaks): enhance gitleaks configuration for IPv4 and IPv6 rules * fix(dependabot.yaml): align Terraform module paths in dependabot configuration * fix(ssm-parameter): pin to explicit version of wrapped module * feat(ssm-parameter): demand `key_id` is given when `type` is SecureString; infer `secure_type` * docs(ssm-parameter): rework readme * fix(ssm-parameter): bring `name` attr hopefuly more in line with what Oliver wants --------- Co-authored-by: Oliver Slater --- .github/dependabot.yaml | 1 + README.md | 1 + .../modules/ssm-parameter/.terraform.lock.hcl | 30 ++ .../modules/ssm-parameter/README.md | 170 ++++++++ .../modules/ssm-parameter/context.tf | 376 ++++++++++++++++++ .../modules/ssm-parameter/locals.tf | 10 + infrastructure/modules/ssm-parameter/main.tf | 21 + .../modules/ssm-parameter/outputs.tf | 46 +++ .../modules/ssm-parameter/variables.tf | 85 ++++ .../modules/ssm-parameter/versions.tf | 10 + 10 files changed, 750 insertions(+) create mode 100644 infrastructure/modules/ssm-parameter/.terraform.lock.hcl create mode 100644 infrastructure/modules/ssm-parameter/README.md create mode 100644 infrastructure/modules/ssm-parameter/context.tf create mode 100644 infrastructure/modules/ssm-parameter/locals.tf create mode 100644 infrastructure/modules/ssm-parameter/main.tf create mode 100644 infrastructure/modules/ssm-parameter/outputs.tf create mode 100644 infrastructure/modules/ssm-parameter/variables.tf create mode 100644 infrastructure/modules/ssm-parameter/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index c57c031e..1428709d 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -74,6 +74,7 @@ updates: - "infrastructure/modules/security-hub" - "infrastructure/modules/sns" - "infrastructure/modules/sqs" + - "infrastructure/modules/ssm-parameter" - "infrastructure/modules/tags" - "infrastructure/modules/vpc" - "infrastructure/modules/vpce" diff --git a/README.md b/README.md index 02582480..82c12e7f 100644 --- a/README.md +++ b/README.md @@ -341,6 +341,7 @@ Rules: | `security-hub` | — | Security Hub for centralized security findings | | `sns` | terraform-aws-modules/sns/aws | SNS topic with encryption and policies | | `sqs` | — | SQS queue with encryption | +| `ssm-parameter` | — | — | | `tags` | — | Foundation: naming and tagging context module | | `vpc` | terraform-aws-modules/vpc/aws | VPC with subnets, routing, and gateways | | `vpce` | — | VPC endpoint (single service) | diff --git a/infrastructure/modules/ssm-parameter/.terraform.lock.hcl b/infrastructure/modules/ssm-parameter/.terraform.lock.hcl new file mode 100644 index 00000000..a3c62fcd --- /dev/null +++ b/infrastructure/modules/ssm-parameter/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.28.0" + hashes = [ + "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:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} diff --git a/infrastructure/modules/ssm-parameter/README.md b/infrastructure/modules/ssm-parameter/README.md new file mode 100644 index 00000000..81e3b3a6 --- /dev/null +++ b/infrastructure/modules/ssm-parameter/README.md @@ -0,0 +1,170 @@ +# SSM Parameter + +NHS Screening wrapper around the community +[`terraform-aws-modules/ssm-parameter/aws`](https://registry.terraform.io/modules/terraform-aws-modules/ssm-parameter/aws/latest) +module that consumes the shared `context.tf` for naming and tagging. + +## What this module enforces + +| Control | How it is enforced | +| --- | --- | +| Naming consistency | Parameter name is derived from context labels; path-style names are normalised to start with `/` | +| Tagging consistency | Tags are always sourced from `module.this.tags` | +| SecureString guardrail | `key_id` is mandatory when `type = "SecureString"` via variable validation | + +## Usage + +### Minimal string parameter + +```hcl +module "app_config_parameter" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" + + service = "bcss" + project = "api" + environment = "development" + name = "log-level" + + type = "String" + value = "INFO" +} +``` + +### Production SecureString parameter with customer-managed KMS key + +```hcl +module "database_password_parameter" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" + + service = "bcss" + project = "database" + environment = "prod" + name = "db-password" + + type = "SecureString" + value = var.db_password + key_id = module.kms.key_arn +} +``` + +### Path-style parameter with value change ignored + +```hcl +module "shared_api_endpoint" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ssm-parameter?ref=" + + service = "bcss" + project = "platform" + environment = "prod" + name = "shared/api/base-url" + + type = "String" + value = "https://api.example.nhs.uk" + ignore_value_changes = true +} +``` + +## Conventions + +* The parameter name comes from context labels. If `name` contains `/`, the module ensures the final parameter name is fully qualified (starts with `/`). +* `type` must be one of `String`, `StringList`, or `SecureString`. +* When using `SecureString`, you must provide `key_id`. +* `values` are JSON-encoded by the upstream module when storing list-style values. +* Set `ignore_value_changes = true` when values are managed outside Terraform and should not be reconciled on every apply. + +## What this module does NOT do + +* Create or manage a KMS key. Provide an existing key ARN/ID via `key_id` for `SecureString` parameters. +* Manage parameter policies (for example expiration, no-change notifications, or advanced policy lifecycle controls). +* Resolve secrets from external systems. The caller must provide `value`, `values`, or `value_wo_version`. +* Manage IAM permissions for reading/writing parameters. Attach IAM policies in consumer stacks/modules. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.28 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [ssm\_parameter](#module\_ssm\_parameter) | terraform-aws-modules/ssm-parameter/aws | 2.1.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 | +| [allowed\_pattern](#input\_allowed\_pattern) | Regular expression used to validate the parameter value | `string` | `null` | no | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | 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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | +| [description](#input\_description) | Description of the parameter | `string` | `null` | 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 | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | +| [ignore\_value\_changes](#input\_ignore\_value\_changes) | Whether to create SSM Parameter and ignore changes in value | `bool` | `false` | no | +| [key\_id](#input\_key\_id) | KMS key ID or ARN for encrypting a `SecureString` | `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 | +| [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 | +| [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [overwrite](#input\_overwrite) | Overwrite an existing parameter. If not specified, defaults to `false` during create operations to avoid overwriting existing resources and then `true` for all subsequent operations once the resource is managed by Terraform | `bool` | `null` | no | +| [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [path](#input\_path) | Path part of the name of the parameter. Defaults to `///` derived from context. | `string` | `null` | 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 | +| [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 | +| [ssm\_data\_type](#input\_ssm\_data\_type) | Data type of the parameter. Valid values: `text`, `aws:ssm:integration` and `aws:ec2:image` for AMI format, see https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-ec2-aliases.html | `string` | `null` | no | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [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 this module path. | `string` | `null` | no | +| [tier](#input\_tier) | Parameter tier to assign to the parameter. If not specified, will use the default parameter tier for the region. Valid tiers are Standard, Advanced, and Intelligent-Tiering. Downgrading an Advanced tier parameter to Standard will recreate the resource | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [type](#input\_type) | Type of the parameter. Valid types are `String`, `StringList` and `SecureString` | `string` | n/a | yes | +| [value](#input\_value) | Value of the parameter | `string` | `null` | no | +| [value\_wo\_version](#input\_value\_wo\_version) | Value of the parameter. This value is always marked as sensitive in the Terraform plan output, regardless of type. Additionally, write-only values are never stored to state. `value_wo_version` can be used to trigger an update and is required with this argument | `number` | `null` | no | +| [values](#input\_values) | List of values of the parameter (will be jsonencoded to store as string natively in SSM) | `list(string)` | `[]` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [insecure\_value](#output\_insecure\_value) | Insecure value of the parameter | +| [raw\_value](#output\_raw\_value) | Raw value of the parameter (as it is stored in SSM). Use 'value' output to get jsondecode'd value | +| [secure\_type](#output\_secure\_type) | Whether SSM parameter is a SecureString or not? | +| [secure\_value](#output\_secure\_value) | Secure value of the parameter | +| [ssm\_parameter\_arn](#output\_ssm\_parameter\_arn) | The ARN of the parameter | +| [ssm\_parameter\_name](#output\_ssm\_parameter\_name) | Name of the parameter | +| [ssm\_parameter\_type](#output\_ssm\_parameter\_type) | Type of the parameter | +| [ssm\_parameter\_version](#output\_ssm\_parameter\_version) | Version of the parameter | +| [value](#output\_value) | Parameter value after jsondecode(). Probably this is what you are looking for | + + + diff --git a/infrastructure/modules/ssm-parameter/context.tf b/infrastructure/modules/ssm-parameter/context.tf new file mode 100644 index 00000000..62befcb0 --- /dev/null +++ b/infrastructure/modules/ssm-parameter/context.tf @@ -0,0 +1,376 @@ +# 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" { + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/ssm-parameter/locals.tf b/infrastructure/modules/ssm-parameter/locals.tf new file mode 100644 index 00000000..bc44a012 --- /dev/null +++ b/infrastructure/modules/ssm-parameter/locals.tf @@ -0,0 +1,10 @@ +locals { + default_path = format( + "/%s/%s/%s", + module.this.service, + module.this.project, + module.this.environment + ) + path = var.path != null ? var.path : local.default_path + name = "${trimsuffix(local.path, "/")}/${module.this.id}" +} diff --git a/infrastructure/modules/ssm-parameter/main.tf b/infrastructure/modules/ssm-parameter/main.tf new file mode 100644 index 00000000..b0dc9fa4 --- /dev/null +++ b/infrastructure/modules/ssm-parameter/main.tf @@ -0,0 +1,21 @@ +module "ssm_parameter" { + source = "terraform-aws-modules/ssm-parameter/aws" + version = "2.1.0" + + create = module.this.enabled + name = local.name + tags = module.this.tags + + allowed_pattern = var.allowed_pattern + data_type = var.ssm_data_type + description = var.description + ignore_value_changes = var.ignore_value_changes + key_id = var.key_id + overwrite = var.overwrite + secure_type = var.type == "SecureString" + tier = var.tier + type = var.type + value = var.value + value_wo_version = var.value_wo_version + values = var.values +} diff --git a/infrastructure/modules/ssm-parameter/outputs.tf b/infrastructure/modules/ssm-parameter/outputs.tf new file mode 100644 index 00000000..96120b71 --- /dev/null +++ b/infrastructure/modules/ssm-parameter/outputs.tf @@ -0,0 +1,46 @@ +output "insecure_value" { + description = "Insecure value of the parameter" + value = module.ssm_parameter.insecure_value +} + +output "raw_value" { + description = "Raw value of the parameter (as it is stored in SSM). Use 'value' output to get jsondecode'd value" + value = module.ssm_parameter.raw_value + sensitive = true +} + +output "secure_type" { + description = "Whether SSM parameter is a SecureString or not?" + value = module.ssm_parameter.secure_type +} + +output "secure_value" { + description = "Secure value of the parameter" + value = module.ssm_parameter.secure_value + sensitive = true +} + +output "ssm_parameter_arn" { + description = "The ARN of the parameter" + value = module.ssm_parameter.ssm_parameter_arn +} + +output "ssm_parameter_name" { + description = "Name of the parameter" + value = module.ssm_parameter.ssm_parameter_name +} + +output "ssm_parameter_type" { + description = "Type of the parameter" + value = module.ssm_parameter.ssm_parameter_type +} + +output "ssm_parameter_version" { + description = "Version of the parameter" + value = module.ssm_parameter.ssm_parameter_version +} + +output "value" { + description = "Parameter value after jsondecode(). Probably this is what you are looking for" + value = module.ssm_parameter.value +} diff --git a/infrastructure/modules/ssm-parameter/variables.tf b/infrastructure/modules/ssm-parameter/variables.tf new file mode 100644 index 00000000..a676a752 --- /dev/null +++ b/infrastructure/modules/ssm-parameter/variables.tf @@ -0,0 +1,85 @@ +variable "allowed_pattern" { + description = "Regular expression used to validate the parameter value" + type = string + default = null +} + +variable "ssm_data_type" { + description = "Data type of the parameter. Valid values: `text`, `aws:ssm:integration` and `aws:ec2:image` for AMI format, see https://docs.aws.amazon.com/systems-manager/latest/userguide/parameter-store-ec2-aliases.html" + type = string + default = null +} + +variable "description" { + description = "Description of the parameter" + type = string + default = null +} + +variable "ignore_value_changes" { + description = "Whether to create SSM Parameter and ignore changes in value" + type = bool + default = false +} + +variable "key_id" { + description = "KMS key ID or ARN for encrypting a `SecureString`" + type = string + default = null + + validation { + condition = var.type != "SecureString" || var.key_id != null + error_message = "`key_id` must be specified when `type` is \"SecureString\"" + } +} + +variable "overwrite" { + description = "Overwrite an existing parameter. If not specified, defaults to `false` during create operations to avoid overwriting existing resources and then `true` for all subsequent operations once the resource is managed by Terraform" + type = bool + default = null +} + +variable "path" { + description = "Path part of the name of the parameter. Defaults to `///` derived from context." + type = string + default = null + + validation { + condition = var.path == null || can(regex("^/", coalesce(var.path, "/"))) + error_message = "path must start with a forward slash, e.g. \"/bcss\"." + } +} + +variable "tier" { + description = "Parameter tier to assign to the parameter. If not specified, will use the default parameter tier for the region. Valid tiers are Standard, Advanced, and Intelligent-Tiering. Downgrading an Advanced tier parameter to Standard will recreate the resource" + type = string + default = null +} + +variable "type" { + description = "Type of the parameter. Valid types are `String`, `StringList` and `SecureString`" + type = string + + validation { + condition = contains(["String", "StringList", "SecureString"], var.type) + error_message = "`type` must be either \"String\", \"StringList\", or \"SecureString\"" + } +} + +variable "value" { + description = "Value of the parameter" + type = string + default = null +} + +variable "value_wo_version" { + description = "Value of the parameter. This value is always marked as sensitive in the Terraform plan output, regardless of type. Additionally, write-only values are never stored to state. `value_wo_version` can be used to trigger an update and is required with this argument" + type = number + default = null +} + +variable "values" { + description = "List of values of the parameter (will be jsonencoded to store as string natively in SSM)" + type = list(string) + default = [] +} diff --git a/infrastructure/modules/ssm-parameter/versions.tf b/infrastructure/modules/ssm-parameter/versions.tf new file mode 100644 index 00000000..bdaae8ef --- /dev/null +++ b/infrastructure/modules/ssm-parameter/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.28" + } + } +} From e2ecc07cd6a9c15bf18541d1970613927c79ccd5 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Fri, 19 Jun 2026 09:20:27 +0100 Subject: [PATCH 093/118] feat(ecs-service): add bare bones service --- .github/dependabot.yaml | 1 + .../modules/ecs-service/.terraform.lock.hcl | 30 ++++++++++++++++ infrastructure/modules/ecs-service/README.md | 36 +++++++++++++++++++ infrastructure/modules/ecs-service/main.tf | 4 +++ infrastructure/modules/ecs-service/outputs.tf | 1 + .../modules/ecs-service/variables.tf | 1 + .../modules/ecs-service/versions.tf | 10 ++++++ 7 files changed, 83 insertions(+) create mode 100644 infrastructure/modules/ecs-service/.terraform.lock.hcl create mode 100644 infrastructure/modules/ecs-service/README.md create mode 100644 infrastructure/modules/ecs-service/main.tf create mode 100644 infrastructure/modules/ecs-service/outputs.tf create mode 100644 infrastructure/modules/ecs-service/variables.tf create mode 100644 infrastructure/modules/ecs-service/versions.tf diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index 1428709d..69eecd92 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -49,6 +49,7 @@ updates: - "infrastructure/modules/ec2-instance" - "infrastructure/modules/ecr" - "infrastructure/modules/ecs-cluster" + - "infrastructure/modules/ecs-service" - "infrastructure/modules/elasticache" - "infrastructure/modules/github-config" - "infrastructure/modules/guardduty" diff --git a/infrastructure/modules/ecs-service/.terraform.lock.hcl b/infrastructure/modules/ecs-service/.terraform.lock.hcl new file mode 100644 index 00000000..eb4f3fd1 --- /dev/null +++ b/infrastructure/modules/ecs-service/.terraform.lock.hcl @@ -0,0 +1,30 @@ +# This file is maintained automatically by "terraform init". +# Manual edits may be lost in future updates. + +provider "registry.terraform.io/hashicorp/aws" { + version = "6.51.0" + constraints = ">= 6.34.0" + hashes = [ + "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:ada97e6be10164f452e278c23412b8597698a9c95ffb68fe83629d63d85906f3", + "zh:c4d73a91810d8dbcf9abbd431d41fcceebb48f8b6fd3c28a84bb3c6ed08be2e9", + "zh:c63ec875d38fc557b16b0b2b0ab1c7635852799453113240e21a52409de94a71", + "zh:cdd0209a755fc3aa14855aa013dae4b166a2fc7f6d3cbb673f7ff2142f5b63a2", + "zh:e5e665a27290391fd1bffc093ab68b596f6c507785be2e3f0949fab4fd6aec1b", + "zh:f6c42046a31d65eff2793737656b38931f90318b53661046bb84326cd4cb558f", + ] +} diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md new file mode 100644 index 00000000..77bae952 --- /dev/null +++ b/infrastructure/modules/ecs-service/README.md @@ -0,0 +1,36 @@ +# DAVEH + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.34 | + +## Providers + +No providers. + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [ecs\_service](#module\_ecs\_service) | terraform-aws-modules/ecs/aws//modules/service | ~> 7.5.0 | + +## Resources + +No resources. + +## Inputs + +No inputs. + +## Outputs + +No outputs. + + + diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf new file mode 100644 index 00000000..359595df --- /dev/null +++ b/infrastructure/modules/ecs-service/main.tf @@ -0,0 +1,4 @@ +module "ecs_service" { + source = "terraform-aws-modules/ecs/aws//modules/service" + version = "~> 7.5.0" +} diff --git a/infrastructure/modules/ecs-service/outputs.tf b/infrastructure/modules/ecs-service/outputs.tf new file mode 100644 index 00000000..c9fb5240 --- /dev/null +++ b/infrastructure/modules/ecs-service/outputs.tf @@ -0,0 +1 @@ +# DAVEH diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf new file mode 100644 index 00000000..c9fb5240 --- /dev/null +++ b/infrastructure/modules/ecs-service/variables.tf @@ -0,0 +1 @@ +# DAVEH diff --git a/infrastructure/modules/ecs-service/versions.tf b/infrastructure/modules/ecs-service/versions.tf new file mode 100644 index 00000000..542c57d5 --- /dev/null +++ b/infrastructure/modules/ecs-service/versions.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.13" + + required_providers { + aws = { + source = "hashicorp/aws" + version = ">= 6.34" + } + } +} From 81032f8676ba18d9bc957b950cb5abadf1a0c53b Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 08:20:32 +0100 Subject: [PATCH 094/118] feat(ecs-service): add context --- infrastructure/modules/ecs-service/README.md | 35 +- infrastructure/modules/ecs-service/context.tf | 376 ++++++++++++++++++ infrastructure/modules/ecs-service/main.tf | 4 + 3 files changed, 414 insertions(+), 1 deletion(-) create mode 100644 infrastructure/modules/ecs-service/context.tf diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 77bae952..d5aaab0e 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -19,6 +19,7 @@ No providers. | Name | Source | Version | | ---- | ------ | ------- | | [ecs\_service](#module\_ecs\_service) | terraform-aws-modules/ecs/aws//modules/service | ~> 7.5.0 | +| [this](#module\_this) | ../tags | n/a | ## Resources @@ -26,7 +27,39 @@ No resources. ## Inputs -No 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 | +| [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | 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 | +| [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | +| [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | +| [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 | +| [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | +| [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 | +| [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 | +| [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 | +| [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 | +| [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 | +| [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | +| [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 this module path. | `string` | `null` | no | +| [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | ## Outputs diff --git a/infrastructure/modules/ecs-service/context.tf b/infrastructure/modules/ecs-service/context.tf new file mode 100644 index 00000000..62befcb0 --- /dev/null +++ b/infrastructure/modules/ecs-service/context.tf @@ -0,0 +1,376 @@ +# 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" { + 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 = {} + # 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. + 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 this module path." +} + +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 +} + +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 diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 359595df..0fc4ded1 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -1,4 +1,8 @@ module "ecs_service" { source = "terraform-aws-modules/ecs/aws//modules/service" version = "~> 7.5.0" + + create = module.this.enabled + name = module.this.name + tags = module.this.tags } From c2b2cc2baecb9d6a5e9d46adf0e20c7e53000fd8 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 08:41:35 +0100 Subject: [PATCH 095/118] feat(ecs-service): add inputs a --- infrastructure/modules/ecs-service/README.md | 8 + infrastructure/modules/ecs-service/main.tf | 9 + .../modules/ecs-service/variables.tf | 214 +++++++++++++++++- 3 files changed, 230 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index d5aaab0e..033cf591 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -30,8 +30,16 @@ 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 | +| [alarms](#input\_alarms) | Information about the CloudWatch alarms |
object({
alarm_names = list(string)
enable = optional(bool, true)
rollback = optional(bool, true)
})
| `null` | no | | [application\_role](#input\_application\_role) | The role the application is performing | `string` | `"General"` | no | +| [assign\_public\_ip](#input\_assign\_public\_ip) | Assign a public IP address to the ENI (Fargate launch type only) | `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 | +| [autoscaling\_max\_capacity](#input\_autoscaling\_max\_capacity) | Maximum number of tasks to run in your service | `number` | `10` | no | +| [autoscaling\_min\_capacity](#input\_autoscaling\_min\_capacity) | Minimum number of tasks to run in your service | `number` | `1` | no | +| [autoscaling\_policies](#input\_autoscaling\_policies) | Map of autoscaling policies to create for the service |
map(object({
name = optional(string) # Will fall back to the key name if not provided
policy_type = optional(string, "TargetTrackingScaling")
predictive_scaling_policy_configuration = optional(object({
max_capacity_breach_behavior = optional(string)
max_capacity_buffer = optional(number)
metric_specification = list(object({
customized_capacity_metric_specification = optional(object({
metric_data_query = list(object({
expression = optional(string)
id = string
label = optional(string)
metric_stat = optional(object({
metric = object({
dimension = optional(list(object({
name = string
value = string
})))
metric_name = optional(string)
namespace = optional(string)
})
stat = string
unit = optional(string)
}))
return_data = optional(bool)
}))
}))
customized_load_metric_specification = optional(object({
metric_data_query = list(object({
expression = optional(string)
id = string
label = optional(string)
metric_stat = optional(object({
metric = object({
dimension = optional(list(object({
name = string
value = string
})))
metric_name = optional(string)
namespace = optional(string)
})
stat = string
unit = optional(string)
}))
return_data = optional(bool)
}))
}))
customized_scaling_metric_specification = optional(object({
metric_data_query = list(object({
expression = optional(string)
id = string
label = optional(string)
metric_stat = optional(object({
metric = object({
dimension = optional(list(object({
name = string
value = string
})))
metric_name = optional(string)
namespace = optional(string)
})
stat = string
unit = optional(string)
}))
return_data = optional(bool)
}))
}))
predefined_load_metric_specification = optional(object({
predefined_metric_type = string
resource_label = optional(string)
}))
predefined_metric_pair_specification = optional(object({
predefined_metric_type = string
resource_label = optional(string)
}))
predefined_scaling_metric_specification = optional(object({
predefined_metric_type = string
resource_label = optional(string)
}))
target_value = number
}))
mode = optional(string)
scheduling_buffer_time = optional(number)
}))
step_scaling_policy_configuration = optional(object({
adjustment_type = optional(string)
cooldown = optional(number)
metric_aggregation_type = optional(string)
min_adjustment_magnitude = optional(number)
step_adjustment = optional(list(object({
metric_interval_lower_bound = optional(string)
metric_interval_upper_bound = optional(string)
scaling_adjustment = number
})))
}))
target_tracking_scaling_policy_configuration = optional(object({
customized_metric_specification = optional(object({
dimensions = optional(list(object({
name = string
value = string
})))
metric_name = optional(string)
metrics = optional(list(object({
expression = optional(string)
id = string
label = optional(string)
metric_stat = optional(object({
metric = object({
dimensions = optional(list(object({
name = string
value = string
})))
metric_name = string
namespace = string
})
stat = string
unit = optional(string)
}))
return_data = optional(bool)
})))
namespace = optional(string)
statistic = optional(string)
unit = optional(string)
}))
disable_scale_in = optional(bool)
predefined_metric_specification = optional(object({
predefined_metric_type = string
resource_label = optional(string)
}))
scale_in_cooldown = optional(number, 300)
scale_out_cooldown = optional(number, 60)
target_value = optional(number, 75)
}))
}))
|
{
"cpu": {
"policy_type": "TargetTrackingScaling",
"target_tracking_scaling_policy_configuration": {
"predefined_metric_specification": {
"predefined_metric_type": "ECSServiceAverageCPUUtilization"
}
}
},
"memory": {
"policy_type": "TargetTrackingScaling",
"target_tracking_scaling_policy_configuration": {
"predefined_metric_specification": {
"predefined_metric_type": "ECSServiceAverageMemoryUtilization"
}
}
}
}
| no | +| [autoscaling\_scheduled\_actions](#input\_autoscaling\_scheduled\_actions) | Map of autoscaling scheduled actions to create for the service |
map(object({
name = optional(string)
min_capacity = number
max_capacity = number
schedule = string
start_time = optional(string)
end_time = optional(string)
timezone = optional(string)
}))
| `null` | no | +| [autoscaling\_suspended\_state](#input\_autoscaling\_suspended\_state) | Configuration block that specifies whether the scaling activities for the service are in a suspended state |
object({
dynamic_scaling_in_suspended = optional(bool, false)
dynamic_scaling_out_suspended = optional(bool, false)
scheduled_scaling_suspended = optional(bool, false)
})
| `null` | no | +| [availability\_zone\_rebalancing](#input\_availability\_zone\_rebalancing) | ECS automatically redistributes tasks within a service across Availability Zones (AZs) to mitigate the risk of impaired application availability due to underlying infrastructure failures and task lifecycle activities. The valid values are `ENABLED` and `DISABLED`. Defaults to `DISABLED` | `string` | `null` | no | | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | 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 | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | no | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 0fc4ded1..7cce6d25 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -5,4 +5,13 @@ module "ecs_service" { create = module.this.enabled name = module.this.name tags = module.this.tags + + alarms = var.alarms + assign_public_ip = var.assign_public_ip + autoscaling_max_capacity = var.autoscaling_max_capacity + autoscaling_min_capacity = var.autoscaling_min_capacity + autoscaling_policies = var.autoscaling_policies + autoscaling_scheduled_actions = var.autoscaling_scheduled_actions + autoscaling_suspended_state = var.autoscaling_suspended_state + availability_zone_rebalancing = var.availability_zone_rebalancing } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index c9fb5240..6a09ccee 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -1 +1,213 @@ -# DAVEH +variable "alarms" { + description = "Information about the CloudWatch alarms" + type = object({ + alarm_names = list(string) + enable = optional(bool, true) + rollback = optional(bool, true) + }) + default = null +} + +variable "assign_public_ip" { + description = "Assign a public IP address to the ENI (Fargate launch type only)" + type = bool + default = false +} + +variable "autoscaling_max_capacity" { + description = "Maximum number of tasks to run in your service" + type = number + default = 10 +} + +variable "autoscaling_min_capacity" { + description = "Minimum number of tasks to run in your service" + type = number + default = 1 +} + +variable "autoscaling_policies" { + description = "Map of autoscaling policies to create for the service" + type = map(object({ + name = optional(string) # Will fall back to the key name if not provided + policy_type = optional(string, "TargetTrackingScaling") + predictive_scaling_policy_configuration = optional(object({ + max_capacity_breach_behavior = optional(string) + max_capacity_buffer = optional(number) + metric_specification = list(object({ + customized_capacity_metric_specification = optional(object({ + metric_data_query = list(object({ + expression = optional(string) + id = string + label = optional(string) + metric_stat = optional(object({ + metric = object({ + dimension = optional(list(object({ + name = string + value = string + }))) + metric_name = optional(string) + namespace = optional(string) + }) + stat = string + unit = optional(string) + })) + return_data = optional(bool) + })) + })) + customized_load_metric_specification = optional(object({ + metric_data_query = list(object({ + expression = optional(string) + id = string + label = optional(string) + metric_stat = optional(object({ + metric = object({ + dimension = optional(list(object({ + name = string + value = string + }))) + metric_name = optional(string) + namespace = optional(string) + }) + stat = string + unit = optional(string) + })) + return_data = optional(bool) + })) + })) + customized_scaling_metric_specification = optional(object({ + metric_data_query = list(object({ + expression = optional(string) + id = string + label = optional(string) + metric_stat = optional(object({ + metric = object({ + dimension = optional(list(object({ + name = string + value = string + }))) + metric_name = optional(string) + namespace = optional(string) + }) + stat = string + unit = optional(string) + })) + return_data = optional(bool) + })) + })) + predefined_load_metric_specification = optional(object({ + predefined_metric_type = string + resource_label = optional(string) + })) + predefined_metric_pair_specification = optional(object({ + predefined_metric_type = string + resource_label = optional(string) + })) + predefined_scaling_metric_specification = optional(object({ + predefined_metric_type = string + resource_label = optional(string) + })) + target_value = number + })) + mode = optional(string) + scheduling_buffer_time = optional(number) + })) + step_scaling_policy_configuration = optional(object({ + adjustment_type = optional(string) + cooldown = optional(number) + metric_aggregation_type = optional(string) + min_adjustment_magnitude = optional(number) + step_adjustment = optional(list(object({ + metric_interval_lower_bound = optional(string) + metric_interval_upper_bound = optional(string) + scaling_adjustment = number + }))) + })) + target_tracking_scaling_policy_configuration = optional(object({ + customized_metric_specification = optional(object({ + dimensions = optional(list(object({ + name = string + value = string + }))) + metric_name = optional(string) + metrics = optional(list(object({ + expression = optional(string) + id = string + label = optional(string) + metric_stat = optional(object({ + metric = object({ + dimensions = optional(list(object({ + name = string + value = string + }))) + metric_name = string + namespace = string + }) + stat = string + unit = optional(string) + })) + return_data = optional(bool) + }))) + namespace = optional(string) + statistic = optional(string) + unit = optional(string) + })) + disable_scale_in = optional(bool) + predefined_metric_specification = optional(object({ + predefined_metric_type = string + resource_label = optional(string) + })) + scale_in_cooldown = optional(number, 300) + scale_out_cooldown = optional(number, 60) + target_value = optional(number, 75) + })) + })) + default = { + "cpu" : { + "policy_type" : "TargetTrackingScaling", + "target_tracking_scaling_policy_configuration" : { + "predefined_metric_specification" : { + "predefined_metric_type" : "ECSServiceAverageCPUUtilization" + } + } + }, + "memory" : { + "policy_type" : "TargetTrackingScaling", + "target_tracking_scaling_policy_configuration" : { + "predefined_metric_specification" : { + "predefined_metric_type" : "ECSServiceAverageMemoryUtilization" + } + } + } + } +} + +variable "autoscaling_scheduled_actions" { + description = "Map of autoscaling scheduled actions to create for the service" + type = map(object({ + name = optional(string) + min_capacity = number + max_capacity = number + schedule = string + start_time = optional(string) + end_time = optional(string) + timezone = optional(string) + })) + default = null +} + +variable "autoscaling_suspended_state" { + description = "Configuration block that specifies whether the scaling activities for the service are in a suspended state" + type = object({ + dynamic_scaling_in_suspended = optional(bool, false) + dynamic_scaling_out_suspended = optional(bool, false) + scheduled_scaling_suspended = optional(bool, false) + }) + default = null +} + +variable "availability_zone_rebalancing" { + description = "ECS automatically redistributes tasks within a service across Availability Zones (AZs) to mitigate the risk of impaired application availability due to underlying infrastructure failures and task lifecycle activities. The valid values are `ENABLED` and `DISABLED`. Defaults to `DISABLED`" + type = string + default = null +} From 2b7a13bfb6a800ccbc4153f0f3fb65cb9561d50e Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 08:55:21 +0100 Subject: [PATCH 096/118] feat(ecs-service): add inputs ca-cp --- infrastructure/modules/ecs-service/README.md | 4 + infrastructure/modules/ecs-service/main.tf | 4 + .../modules/ecs-service/variables.tf | 165 ++++++++++++++++++ 3 files changed, 173 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 033cf591..49e58db7 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -41,7 +41,11 @@ No resources. | [autoscaling\_suspended\_state](#input\_autoscaling\_suspended\_state) | Configuration block that specifies whether the scaling activities for the service are in a suspended state |
object({
dynamic_scaling_in_suspended = optional(bool, false)
dynamic_scaling_out_suspended = optional(bool, false)
scheduled_scaling_suspended = optional(bool, false)
})
| `null` | no | | [availability\_zone\_rebalancing](#input\_availability\_zone\_rebalancing) | ECS automatically redistributes tasks within a service across Availability Zones (AZs) to mitigate the risk of impaired application availability due to underlying infrastructure failures and task lifecycle activities. The valid values are `ENABLED` and `DISABLED`. Defaults to `DISABLED` | `string` | `null` | no | | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | +| [capacity\_provider\_strategy](#input\_capacity\_provider\_strategy) | Capacity provider strategies to use for the service. Can be one or more |
map(object({
base = optional(number)
capacity_provider = string
weight = optional(number)
}))
| `null` | no | +| [cluster\_arn](#input\_cluster\_arn) | ARN of the ECS cluster where the resources will be provisioned | `string` | `""` | no | +| [container\_definitions](#input\_container\_definitions) | A map of valid container definitions . Please note that you should only provide values that are part of the container definition document |
map(object({
create = optional(bool, true)
operating_system_family = optional(string)
tags = optional(map(string)) # Container definition
command = optional(list(string))
cpu = optional(number)
credentialSpecs = optional(list(string))
dependsOn = optional(list(object({
condition = string
containerName = string
})))
disableNetworking = optional(bool)
dnsSearchDomains = optional(list(string))
dnsServers = optional(list(string))
dockerLabels = optional(map(string))
dockerSecurityOptions = optional(list(string))
# DAVEH: following line was comment to preceeding line
enable_execute_command = optional(bool, false) # Set in standalone variable
entrypoint = optional(list(string))
environment = optional(list(object({
name = string
value = string
})))
environmentFiles = optional(list(object({
type = string
value = string
})))
essential = optional(bool)
extraHosts = optional(list(object({
hostname = string
ipAddress = string
})))
firelensConfiguration = optional(object({
options = optional(map(string))
type = optional(string)
}))
healthCheck = optional(object({
command = optional(list(string), [])
interval = optional(number, 30)
retries = optional(number, 3)
startPeriod = optional(number)
timeout = optional(number, 5)
}))
hostname = optional(string)
image = optional(string)
interactive = optional(bool)
links = optional(list(string))
linuxParameters = optional(object({
capabilities = optional(object({
add = optional(list(string))
drop = optional(list(string))
}))
devices = optional(list(object({
containerPath = optional(string)
hostPath = optional(string)
permissions = optional(list(string))
})))
initProcessEnabled = optional(bool)
maxSwap = optional(number)
sharedMemorySize = optional(number)
swappiness = optional(number)
tmpfs = optional(list(object({
containerPath = string
mountOptions = optional(list(string))
size = number
})))
}))
logConfiguration = optional(object({
logDriver = optional(string)
options = optional(map(string))
secretOptions = optional(list(object({
name = string
valueFrom = string
})))
}))
memory = optional(number)
memoryReservation = optional(number)
mountPoints = optional(list(object({
containerPath = optional(string)
readOnly = optional(bool)
sourceVolume = optional(string)
})))
name = optional(string)
portMappings = optional(list(object({
appProtocol = optional(string)
containerPort = optional(number)
containerPortRange = optional(string)
hostPort = optional(number)
name = optional(string)
protocol = optional(string)
})))
privileged = optional(bool)
pseudoTerminal = optional(bool)
readonlyRootFilesystem = optional(bool)
repositoryCredentials = optional(object({
credentialsParameter = optional(string)
}))
resourceRequirements = optional(list(object({
type = string
value = string
})))
restartPolicy = optional(object({
enabled = optional(bool)
ignoredExitCodes = optional(list(number))
restartAttemptPeriod = optional(number)
}))
secrets = optional(list(object({
name = string
valueFrom = string
})))
startTimeout = optional(number, 30)
stopTimeout = optional(number, 120)
systemControls = optional(list(object({
namespace = optional(string)
value = optional(string)
})))
ulimits = optional(list(object({
hardLimit = number
name = string
softLimit = number
})))
user = optional(string)
versionConsistency = optional(string)
volumesFrom = optional(list(object({
readOnly = optional(bool)
sourceContainer = optional(string)
})))
workingDirectory = optional(string)
# Cloudwatch Log Group
service = optional(string)
enable_cloudwatch_logging = optional(bool)
create_cloudwatch_log_group = optional(bool)
cloudwatch_log_group_name = optional(string)
cloudwatch_log_group_use_name_prefix = optional(bool)
cloudwatch_log_group_class = optional(string)
cloudwatch_log_group_retention_in_days = optional(number)
cloudwatch_log_group_kms_key_id = optional(string)
}))
| `{}` | 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 | +| [cpu](#input\_cpu) | Number of cpu units used by the task. If the `requires_compatibilities` is `FARGATE` this field is required | `number` | `1024` | no | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 7cce6d25..18ca2eb6 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -14,4 +14,8 @@ module "ecs_service" { autoscaling_scheduled_actions = var.autoscaling_scheduled_actions autoscaling_suspended_state = var.autoscaling_suspended_state availability_zone_rebalancing = var.availability_zone_rebalancing + capacity_provider_strategy = var.capacity_provider_strategy + cluster_arn = var.cluster_arn + container_definitions = var.container_definitions + cpu = var.cpu } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 6a09ccee..221859a5 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -211,3 +211,168 @@ variable "availability_zone_rebalancing" { type = string default = null } + +variable "capacity_provider_strategy" { + description = "Capacity provider strategies to use for the service. Can be one or more" + type = map(object({ + base = optional(number) + capacity_provider = string + weight = optional(number) + })) + default = null +} + +variable "cluster_arn" { + description = "ARN of the ECS cluster where the resources will be provisioned" + type = string + default = "" +} + +variable "container_definitions" { + description = "A map of valid container definitions . Please note that you should only provide values that are part of the container definition document" + type = map(object({ + create = optional(bool, true) + operating_system_family = optional(string) + tags = optional(map(string)) # Container definition + command = optional(list(string)) + cpu = optional(number) + credentialSpecs = optional(list(string)) + dependsOn = optional(list(object({ + condition = string + containerName = string + }))) + disableNetworking = optional(bool) + dnsSearchDomains = optional(list(string)) + dnsServers = optional(list(string)) + dockerLabels = optional(map(string)) + dockerSecurityOptions = optional(list(string)) + # DAVEH: following line was comment to preceeding line + enable_execute_command = optional(bool, false) # Set in standalone variable + entrypoint = optional(list(string)) + environment = optional(list(object({ + name = string + value = string + }))) + environmentFiles = optional(list(object({ + type = string + value = string + }))) + essential = optional(bool) + extraHosts = optional(list(object({ + hostname = string + ipAddress = string + }))) + firelensConfiguration = optional(object({ + options = optional(map(string)) + type = optional(string) + })) + healthCheck = optional(object({ + command = optional(list(string), []) + interval = optional(number, 30) + retries = optional(number, 3) + startPeriod = optional(number) + timeout = optional(number, 5) + })) + hostname = optional(string) + image = optional(string) + interactive = optional(bool) + links = optional(list(string)) + linuxParameters = optional(object({ + capabilities = optional(object({ + add = optional(list(string)) + drop = optional(list(string)) + })) + devices = optional(list(object({ + containerPath = optional(string) + hostPath = optional(string) + permissions = optional(list(string)) + }))) + initProcessEnabled = optional(bool) + maxSwap = optional(number) + sharedMemorySize = optional(number) + swappiness = optional(number) + tmpfs = optional(list(object({ + containerPath = string + mountOptions = optional(list(string)) + size = number + }))) + })) + logConfiguration = optional(object({ + logDriver = optional(string) + options = optional(map(string)) + secretOptions = optional(list(object({ + name = string + valueFrom = string + }))) + })) + memory = optional(number) + memoryReservation = optional(number) + mountPoints = optional(list(object({ + containerPath = optional(string) + readOnly = optional(bool) + sourceVolume = optional(string) + }))) + name = optional(string) + portMappings = optional(list(object({ + appProtocol = optional(string) + containerPort = optional(number) + containerPortRange = optional(string) + hostPort = optional(number) + name = optional(string) + protocol = optional(string) + }))) + privileged = optional(bool) + pseudoTerminal = optional(bool) + readonlyRootFilesystem = optional(bool) + repositoryCredentials = optional(object({ + credentialsParameter = optional(string) + })) + resourceRequirements = optional(list(object({ + type = string + value = string + }))) + restartPolicy = optional(object({ + enabled = optional(bool) + ignoredExitCodes = optional(list(number)) + restartAttemptPeriod = optional(number) + })) + secrets = optional(list(object({ + name = string + valueFrom = string + }))) + startTimeout = optional(number, 30) + stopTimeout = optional(number, 120) + systemControls = optional(list(object({ + namespace = optional(string) + value = optional(string) + }))) + ulimits = optional(list(object({ + hardLimit = number + name = string + softLimit = number + }))) + user = optional(string) + versionConsistency = optional(string) + volumesFrom = optional(list(object({ + readOnly = optional(bool) + sourceContainer = optional(string) + }))) + workingDirectory = optional(string) + # Cloudwatch Log Group + service = optional(string) + enable_cloudwatch_logging = optional(bool) + create_cloudwatch_log_group = optional(bool) + cloudwatch_log_group_name = optional(string) + cloudwatch_log_group_use_name_prefix = optional(bool) + cloudwatch_log_group_class = optional(string) + cloudwatch_log_group_retention_in_days = optional(number) + cloudwatch_log_group_kms_key_id = optional(string) + })) + default = {} +} + +variable "cpu" { + description = "Number of cpu units used by the task. If the `requires_compatibilities` is `FARGATE` this field is required" + type = number + default = 1024 +} From 78a1a038c930be9281de290ef01dd2603ef95b9a Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 09:09:18 +0100 Subject: [PATCH 097/118] feat(ecs-service): add inputs cr --- infrastructure/modules/ecs-service/README.md | 8 ++++ infrastructure/modules/ecs-service/main.tf | 32 ++++++++----- .../modules/ecs-service/variables.tf | 48 +++++++++++++++++++ 3 files changed, 76 insertions(+), 12 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 49e58db7..29e24624 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -46,6 +46,14 @@ No resources. | [container\_definitions](#input\_container\_definitions) | A map of valid container definitions . Please note that you should only provide values that are part of the container definition document |
map(object({
create = optional(bool, true)
operating_system_family = optional(string)
tags = optional(map(string)) # Container definition
command = optional(list(string))
cpu = optional(number)
credentialSpecs = optional(list(string))
dependsOn = optional(list(object({
condition = string
containerName = string
})))
disableNetworking = optional(bool)
dnsSearchDomains = optional(list(string))
dnsServers = optional(list(string))
dockerLabels = optional(map(string))
dockerSecurityOptions = optional(list(string))
# DAVEH: following line was comment to preceeding line
enable_execute_command = optional(bool, false) # Set in standalone variable
entrypoint = optional(list(string))
environment = optional(list(object({
name = string
value = string
})))
environmentFiles = optional(list(object({
type = string
value = string
})))
essential = optional(bool)
extraHosts = optional(list(object({
hostname = string
ipAddress = string
})))
firelensConfiguration = optional(object({
options = optional(map(string))
type = optional(string)
}))
healthCheck = optional(object({
command = optional(list(string), [])
interval = optional(number, 30)
retries = optional(number, 3)
startPeriod = optional(number)
timeout = optional(number, 5)
}))
hostname = optional(string)
image = optional(string)
interactive = optional(bool)
links = optional(list(string))
linuxParameters = optional(object({
capabilities = optional(object({
add = optional(list(string))
drop = optional(list(string))
}))
devices = optional(list(object({
containerPath = optional(string)
hostPath = optional(string)
permissions = optional(list(string))
})))
initProcessEnabled = optional(bool)
maxSwap = optional(number)
sharedMemorySize = optional(number)
swappiness = optional(number)
tmpfs = optional(list(object({
containerPath = string
mountOptions = optional(list(string))
size = number
})))
}))
logConfiguration = optional(object({
logDriver = optional(string)
options = optional(map(string))
secretOptions = optional(list(object({
name = string
valueFrom = string
})))
}))
memory = optional(number)
memoryReservation = optional(number)
mountPoints = optional(list(object({
containerPath = optional(string)
readOnly = optional(bool)
sourceVolume = optional(string)
})))
name = optional(string)
portMappings = optional(list(object({
appProtocol = optional(string)
containerPort = optional(number)
containerPortRange = optional(string)
hostPort = optional(number)
name = optional(string)
protocol = optional(string)
})))
privileged = optional(bool)
pseudoTerminal = optional(bool)
readonlyRootFilesystem = optional(bool)
repositoryCredentials = optional(object({
credentialsParameter = optional(string)
}))
resourceRequirements = optional(list(object({
type = string
value = string
})))
restartPolicy = optional(object({
enabled = optional(bool)
ignoredExitCodes = optional(list(number))
restartAttemptPeriod = optional(number)
}))
secrets = optional(list(object({
name = string
valueFrom = string
})))
startTimeout = optional(number, 30)
stopTimeout = optional(number, 120)
systemControls = optional(list(object({
namespace = optional(string)
value = optional(string)
})))
ulimits = optional(list(object({
hardLimit = number
name = string
softLimit = number
})))
user = optional(string)
versionConsistency = optional(string)
volumesFrom = optional(list(object({
readOnly = optional(bool)
sourceContainer = optional(string)
})))
workingDirectory = optional(string)
# Cloudwatch Log Group
service = optional(string)
enable_cloudwatch_logging = optional(bool)
create_cloudwatch_log_group = optional(bool)
cloudwatch_log_group_name = optional(string)
cloudwatch_log_group_use_name_prefix = optional(bool)
cloudwatch_log_group_class = optional(string)
cloudwatch_log_group_retention_in_days = optional(number)
cloudwatch_log_group_kms_key_id = optional(string)
}))
| `{}` | 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 | | [cpu](#input\_cpu) | Number of cpu units used by the task. If the `requires_compatibilities` is `FARGATE` this field is required | `number` | `1024` | no | +| [create\_iam\_role](#input\_create\_iam\_role) | Determines whether the ECS service IAM role should be created | `bool` | `true` | no | +| [create\_infrastructure\_iam\_role](#input\_create\_infrastructure\_iam\_role) | Determines whether the ECS infrastructure IAM role should be created | `bool` | `true` | no | +| [create\_security\_group](#input\_create\_security\_group) | Determines if a security group is created | `bool` | `true` | no | +| [create\_service](#input\_create\_service) | Determines whether service resource will be created (set to `false` in case you want to create task definition only) | `bool` | `true` | no | +| [create\_task\_definition](#input\_create\_task\_definition) | Determines whether to create a task definition or use existing/provided | `bool` | `true` | no | +| [create\_task\_exec\_iam\_role](#input\_create\_task\_exec\_iam\_role) | Determines whether the ECS task definition IAM role should be created | `bool` | `true` | no | +| [create\_task\_exec\_policy](#input\_create\_task\_exec\_policy) | Determines whether the ECS task definition IAM policy should be created. This includes permissions included in AmazonECSTaskExecutionRolePolicy as well as access to secrets and SSM parameters | `bool` | `true` | no | +| [create\_tasks\_iam\_role](#input\_create\_tasks\_iam\_role) | Determines whether the ECS tasks IAM role should be created | `bool` | `true` | no | | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 18ca2eb6..1ca8f043 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -6,16 +6,24 @@ module "ecs_service" { name = module.this.name tags = module.this.tags - alarms = var.alarms - assign_public_ip = var.assign_public_ip - autoscaling_max_capacity = var.autoscaling_max_capacity - autoscaling_min_capacity = var.autoscaling_min_capacity - autoscaling_policies = var.autoscaling_policies - autoscaling_scheduled_actions = var.autoscaling_scheduled_actions - autoscaling_suspended_state = var.autoscaling_suspended_state - availability_zone_rebalancing = var.availability_zone_rebalancing - capacity_provider_strategy = var.capacity_provider_strategy - cluster_arn = var.cluster_arn - container_definitions = var.container_definitions - cpu = var.cpu + alarms = var.alarms + assign_public_ip = var.assign_public_ip + autoscaling_max_capacity = var.autoscaling_max_capacity + autoscaling_min_capacity = var.autoscaling_min_capacity + autoscaling_policies = var.autoscaling_policies + autoscaling_scheduled_actions = var.autoscaling_scheduled_actions + autoscaling_suspended_state = var.autoscaling_suspended_state + availability_zone_rebalancing = var.availability_zone_rebalancing + capacity_provider_strategy = var.capacity_provider_strategy + cluster_arn = var.cluster_arn + container_definitions = var.container_definitions + cpu = var.cpu + create_iam_role = var.create_iam_role + create_infrastructure_iam_role = var.create_infrastructure_iam_role + create_security_group = var.create_security_group + create_service = var.create_service + create_task_definition = var.create_task_definition + create_task_exec_iam_role = var.create_task_exec_iam_role + create_task_exec_policy = var.create_task_exec_policy + create_tasks_iam_role = var.create_tasks_iam_role } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 221859a5..c0f8a902 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -376,3 +376,51 @@ variable "cpu" { type = number default = 1024 } + +variable "create_iam_role" { + description = "Determines whether the ECS service IAM role should be created" + type = bool + default = true +} + +variable "create_infrastructure_iam_role" { + description = "Determines whether the ECS infrastructure IAM role should be created" + type = bool + default = true +} + +variable "create_security_group" { + description = "Determines if a security group is created" + type = bool + default = true +} + +variable "create_service" { + description = "Determines whether service resource will be created (set to `false` in case you want to create task definition only)" + type = bool + default = true +} + +variable "create_task_definition" { + description = "Determines whether to create a task definition or use existing/provided" + type = bool + default = true +} + +variable "create_task_exec_iam_role" { + description = "Determines whether the ECS task definition IAM role should be created" + type = bool + default = true +} + +variable "create_task_exec_policy" { + description = "Determines whether the ECS task definition IAM policy should be created. This includes permissions included in AmazonECSTaskExecutionRolePolicy as well as access to secrets and SSM parameters" + type = bool + default = true +} + +variable "create_tasks_iam_role" { + description = "Determines whether the ECS tasks IAM role should be created" + type = bool + default = true +} From 7fd0ae74a49b42cb7b0cac26cc23f844d01a8e31 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 09:22:29 +0100 Subject: [PATCH 098/118] feat(ecs-service): add inputs d --- infrastructure/modules/ecs-service/README.md | 6 ++ infrastructure/modules/ecs-service/main.tf | 46 ++++++++------- .../modules/ecs-service/variables.tf | 58 +++++++++++++++++++ 3 files changed, 90 insertions(+), 20 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 29e24624..c66e08f8 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -57,7 +57,13 @@ No resources. | [data\_classification](#input\_data\_classification) | Used to identify the data classification of the resource, e.g 1-5 | `string` | `"n/a"` | 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\_circuit\_breaker](#input\_deployment\_circuit\_breaker) | Configuration block for deployment circuit breaker |
object({
enable = bool
rollback = bool
})
| `null` | no | +| [deployment\_configuration](#input\_deployment\_configuration) | Configuration block for deployment settings |
object({
strategy = optional(string)
bake_time_in_minutes = optional(string)
canary_configuration = optional(object({
canary_bake_time_in_minutes = optional(string)
canary_percent = optional(string)
}))
linear_configuration = optional(object({
step_bake_time_in_minutes = optional(string)
step_percent = optional(string)
}))
lifecycle_hook = optional(map(object({
hook_target_arn = string
role_arn = optional(string)
lifecycle_stages = list(string)
hook_details = optional(string)
})))
})
| `null` | no | +| [deployment\_controller](#input\_deployment\_controller) | Configuration block for deployment controller configuration |
object({
type = optional(string)
})
| `null` | no | +| [deployment\_maximum\_percent](#input\_deployment\_maximum\_percent) | Upper limit (as a percentage of the service's `desired_count`) of the number of running tasks that can be running in a service during a deployment | `number` | `200` | no | +| [deployment\_minimum\_healthy\_percent](#input\_deployment\_minimum\_healthy\_percent) | Lower limit (as a percentage of the service's `desired_count`) of the number of running tasks that must remain running and healthy in a service during a deployment | `number` | `66` | 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 | +| [desired\_count](#input\_desired\_count) | Number of instances of the task definition to place and keep running | `number` | `1` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 1ca8f043..921b7137 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -6,24 +6,30 @@ module "ecs_service" { name = module.this.name tags = module.this.tags - alarms = var.alarms - assign_public_ip = var.assign_public_ip - autoscaling_max_capacity = var.autoscaling_max_capacity - autoscaling_min_capacity = var.autoscaling_min_capacity - autoscaling_policies = var.autoscaling_policies - autoscaling_scheduled_actions = var.autoscaling_scheduled_actions - autoscaling_suspended_state = var.autoscaling_suspended_state - availability_zone_rebalancing = var.availability_zone_rebalancing - capacity_provider_strategy = var.capacity_provider_strategy - cluster_arn = var.cluster_arn - container_definitions = var.container_definitions - cpu = var.cpu - create_iam_role = var.create_iam_role - create_infrastructure_iam_role = var.create_infrastructure_iam_role - create_security_group = var.create_security_group - create_service = var.create_service - create_task_definition = var.create_task_definition - create_task_exec_iam_role = var.create_task_exec_iam_role - create_task_exec_policy = var.create_task_exec_policy - create_tasks_iam_role = var.create_tasks_iam_role + alarms = var.alarms + assign_public_ip = var.assign_public_ip + autoscaling_max_capacity = var.autoscaling_max_capacity + autoscaling_min_capacity = var.autoscaling_min_capacity + autoscaling_policies = var.autoscaling_policies + autoscaling_scheduled_actions = var.autoscaling_scheduled_actions + autoscaling_suspended_state = var.autoscaling_suspended_state + availability_zone_rebalancing = var.availability_zone_rebalancing + capacity_provider_strategy = var.capacity_provider_strategy + cluster_arn = var.cluster_arn + container_definitions = var.container_definitions + cpu = var.cpu + create_iam_role = var.create_iam_role + create_infrastructure_iam_role = var.create_infrastructure_iam_role + create_security_group = var.create_security_group + create_service = var.create_service + create_task_definition = var.create_task_definition + create_task_exec_iam_role = var.create_task_exec_iam_role + create_task_exec_policy = var.create_task_exec_policy + create_tasks_iam_role = var.create_tasks_iam_role + deployment_circuit_breaker = var.deployment_circuit_breaker + deployment_configuration = var.deployment_configuration + deployment_controller = var.deployment_controller + deployment_maximum_percent = var.deployment_maximum_percent + deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent + desired_count = var.desired_count } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index c0f8a902..82f48e7d 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -424,3 +424,61 @@ variable "create_tasks_iam_role" { type = bool default = true } + +variable "deployment_circuit_breaker" { + description = "Configuration block for deployment circuit breaker" + type = object({ + enable = bool + rollback = bool + }) + default = null +} + +variable "deployment_configuration" { + description = "Configuration block for deployment settings" + type = object({ + strategy = optional(string) + bake_time_in_minutes = optional(string) + canary_configuration = optional(object({ + canary_bake_time_in_minutes = optional(string) + canary_percent = optional(string) + })) + linear_configuration = optional(object({ + step_bake_time_in_minutes = optional(string) + step_percent = optional(string) + })) + lifecycle_hook = optional(map(object({ + hook_target_arn = string + role_arn = optional(string) + lifecycle_stages = list(string) + hook_details = optional(string) + }))) + }) + default = null +} + +variable "deployment_controller" { + description = "Configuration block for deployment controller configuration" + type = object({ + type = optional(string) + }) + default = null +} + +variable "deployment_maximum_percent" { + description = "Upper limit (as a percentage of the service's `desired_count`) of the number of running tasks that can be running in a service during a deployment" + type = number + default = 200 +} + +variable "deployment_minimum_healthy_percent" { + description = "Lower limit (as a percentage of the service's `desired_count`) of the number of running tasks that must remain running and healthy in a service during a deployment" + type = number + default = 66 +} + +variable "desired_count" { + description = "Number of instances of the task definition to place and keep running" + type = number + default = 1 +} From 2468b7a65589a56459b3883b6e318c2e9c132775 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:10:05 +0100 Subject: [PATCH 099/118] feat(ecs-service): add inputs e --- infrastructure/modules/ecs-service/README.md | 6 +++ infrastructure/modules/ecs-service/main.tf | 6 +++ .../modules/ecs-service/variables.tf | 38 +++++++++++++++++++ 3 files changed, 50 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index c66e08f8..9ed3b128 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -64,8 +64,14 @@ No resources. | [deployment\_minimum\_healthy\_percent](#input\_deployment\_minimum\_healthy\_percent) | Lower limit (as a percentage of the service's `desired_count`) of the number of running tasks that must remain running and healthy in a service during a deployment | `number` | `66` | 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 | | [desired\_count](#input\_desired\_count) | Number of instances of the task definition to place and keep running | `number` | `1` | no | +| [enable\_autoscaling](#input\_enable\_autoscaling) | Determines whether to enable autoscaling for the service | `bool` | `true` | no | +| [enable\_ecs\_managed\_tags](#input\_enable\_ecs\_managed\_tags) | Specifies whether to enable Amazon ECS managed tags for the tasks within the service | `bool` | `true` | no | +| [enable\_execute\_command](#input\_enable\_execute\_command) | Specifies whether to enable Amazon ECS Exec for the tasks within the service | `bool` | `false` | no | +| [enable\_fault\_injection](#input\_enable\_fault\_injection) | Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false` | `bool` | `null` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | +| [ephemeral\_storage](#input\_ephemeral\_storage) | The amount of ephemeral storage to allocate for the task. This parameter is used to expand the total amount of ephemeral storage available, beyond the default amount, for tasks hosted on AWS Fargate |
object({
size_in_gib = number
})
| `null` | no | +| [external\_id](#input\_external\_id) | The external ID associated with the task set | `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 | | [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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 921b7137..11d211b2 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -32,4 +32,10 @@ module "ecs_service" { deployment_maximum_percent = var.deployment_maximum_percent deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent desired_count = var.desired_count + enable_autoscaling = var.enable_autoscaling + enable_ecs_managed_tags = var.enable_ecs_managed_tags + enable_execute_command = var.enable_execute_command + enable_fault_injection = var.enable_fault_injection + ephemeral_storage = var.ephemeral_storage + external_id = var.external_id } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 82f48e7d..0c96885a 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -482,3 +482,41 @@ variable "desired_count" { type = number default = 1 } + +variable "enable_autoscaling" { + description = "Determines whether to enable autoscaling for the service" + type = bool + default = true +} + +variable "enable_ecs_managed_tags" { + description = "Specifies whether to enable Amazon ECS managed tags for the tasks within the service" + type = bool + default = true +} + +variable "enable_execute_command" { + description = "Specifies whether to enable Amazon ECS Exec for the tasks within the service" + type = bool + default = false +} + +variable "enable_fault_injection" { + description = "Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false`" + type = bool + default = null +} + +variable "ephemeral_storage" { + description = "The amount of ephemeral storage to allocate for the task. This parameter is used to expand the total amount of ephemeral storage available, beyond the default amount, for tasks hosted on AWS Fargate" + type = object({ + size_in_gib = number + }) + default = null +} + +variable "external_id" { + description = "The external ID associated with the task set" + type = string + default = null +} From 3c3f7c60c6a3f4098d4f41885b52bb92d783e277 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:15:15 +0100 Subject: [PATCH 100/118] feat(ecs-service): add inputs f-h --- infrastructure/modules/ecs-service/README.md | 3 +++ infrastructure/modules/ecs-service/main.tf | 3 +++ .../modules/ecs-service/variables.tf | 18 ++++++++++++++++++ 3 files changed, 24 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 9ed3b128..cd203a68 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -72,6 +72,9 @@ No resources. | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | | [ephemeral\_storage](#input\_ephemeral\_storage) | The amount of ephemeral storage to allocate for the task. This parameter is used to expand the total amount of ephemeral storage available, beyond the default amount, for tasks hosted on AWS Fargate |
object({
size_in_gib = number
})
| `null` | no | | [external\_id](#input\_external\_id) | The external ID associated with the task set | `string` | `null` | no | +| [force\_delete](#input\_force\_delete) | Enable to delete a service even if it wasn't scaled down to zero tasks. It's only necessary to use this if the service uses the `REPLICA` scheduling strategy | `bool` | `null` | no | +| [force\_new\_deployment](#input\_force\_new\_deployment) | Enable to force a new task deployment of the service. This can be used to update tasks to use a newer Docker image with same image/tag combination, roll Fargate tasks onto a newer platform version, or immediately deploy `ordered_placement_strategy` and `placement_constraints` updates | `bool` | `true` | no | +| [health\_check\_grace\_period\_seconds](#input\_health\_check\_grace\_period\_seconds) | Seconds to ignore failing load balancer health checks on newly instantiated tasks to prevent premature shutdown, up to 2147483647. Only valid for services configured to use load balancers | `number` | `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 | | [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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 11d211b2..13b535f2 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -38,4 +38,7 @@ module "ecs_service" { enable_fault_injection = var.enable_fault_injection ephemeral_storage = var.ephemeral_storage external_id = var.external_id + force_delete = var.force_delete + force_new_deployment = var.force_new_deployment + health_check_grace_period_seconds = var.health_check_grace_period_seconds } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 0c96885a..1032fcfa 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -520,3 +520,21 @@ variable "external_id" { type = string default = null } + +variable "force_delete" { + description = "Enable to delete a service even if it wasn't scaled down to zero tasks. It's only necessary to use this if the service uses the `REPLICA` scheduling strategy" + type = bool + default = null +} + +variable "force_new_deployment" { + description = "Enable to force a new task deployment of the service. This can be used to update tasks to use a newer Docker image with same image/tag combination, roll Fargate tasks onto a newer platform version, or immediately deploy `ordered_placement_strategy` and `placement_constraints` updates" + type = bool + default = true +} + +variable "health_check_grace_period_seconds" { + description = "Seconds to ignore failing load balancer health checks on newly instantiated tasks to prevent premature shutdown, up to 2147483647. Only valid for services configured to use load balancers" + type = number + default = null +} From 60f16b1776e115525ddd0a99b8ba8a135c0bbb22 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:24:48 +0100 Subject: [PATCH 101/118] feat(ecs-service): add inputs ia --- infrastructure/modules/ecs-service/README.md | 8 +++ infrastructure/modules/ecs-service/main.tf | 8 +++ .../modules/ecs-service/variables.tf | 68 +++++++++++++++++++ 3 files changed, 84 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index cd203a68..118f9d3d 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -75,6 +75,14 @@ No resources. | [force\_delete](#input\_force\_delete) | Enable to delete a service even if it wasn't scaled down to zero tasks. It's only necessary to use this if the service uses the `REPLICA` scheduling strategy | `bool` | `null` | no | | [force\_new\_deployment](#input\_force\_new\_deployment) | Enable to force a new task deployment of the service. This can be used to update tasks to use a newer Docker image with same image/tag combination, roll Fargate tasks onto a newer platform version, or immediately deploy `ordered_placement_strategy` and `placement_constraints` updates | `bool` | `true` | no | | [health\_check\_grace\_period\_seconds](#input\_health\_check\_grace\_period\_seconds) | Seconds to ignore failing load balancer health checks on newly instantiated tasks to prevent premature shutdown, up to 2147483647. Only valid for services configured to use load balancers | `number` | `null` | no | +| [iam\_role\_arn](#input\_iam\_role\_arn) | Existing IAM role ARN | `string` | `null` | no | +| [iam\_role\_description](#input\_iam\_role\_description) | Description of the role | `string` | `null` | no | +| [iam\_role\_name](#input\_iam\_role\_name) | Name to use on IAM role created | `string` | `null` | no | +| [iam\_role\_path](#input\_iam\_role\_path) | IAM role path | `string` | `null` | no | +| [iam\_role\_permissions\_boundary](#input\_iam\_role\_permissions\_boundary) | ARN of the policy that is used to set the permissions boundary for the IAM role | `string` | `null` | no | +| [iam\_role\_statements](#input\_iam\_role\_statements) | A map of IAM policy statements for custom permission usage |
list(object({
sid = optional(string)
actions = optional(list(string))
not_actions = optional(list(string))
effect = optional(string)
resources = optional(list(string))
not_resources = optional(list(string))
principals = optional(list(object({
type = string
identifiers = list(string)
})))
not_principals = optional(list(object({
type = string
identifiers = list(string)
})))
condition = optional(list(object({
test = string
values = list(string)
variable = string
})))
}))
| `null` | no | +| [iam\_role\_tags](#input\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | +| [iam\_role\_use\_name\_prefix](#input\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`iam_role_name`) is used as a prefix | `bool` | `true` | 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 | | [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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 13b535f2..b3ad4100 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -41,4 +41,12 @@ module "ecs_service" { force_delete = var.force_delete force_new_deployment = var.force_new_deployment health_check_grace_period_seconds = var.health_check_grace_period_seconds + iam_role_arn = var.iam_role_arn + iam_role_description = var.iam_role_description + iam_role_name = var.iam_role_name + iam_role_path = var.iam_role_path + iam_role_permissions_boundary = var.iam_role_permissions_boundary + iam_role_statements = var.iam_role_statements + iam_role_tags = var.iam_role_tags + iam_role_use_name_prefix = var.iam_role_use_name_prefix } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 1032fcfa..42c40a0f 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -538,3 +538,71 @@ variable "health_check_grace_period_seconds" { type = number default = null } + +variable "iam_role_arn" { + description = "Existing IAM role ARN" + type = string + default = null +} + +variable "iam_role_description" { + description = "Description of the role" + type = string + default = null +} + +variable "iam_role_name" { + description = "Name to use on IAM role created" + type = string + default = null +} + +variable "iam_role_path" { + description = "IAM role path" + type = string + default = null +} + +variable "iam_role_permissions_boundary" { + description = "ARN of the policy that is used to set the permissions boundary for the IAM role" + type = string + default = null +} + +variable "iam_role_statements" { + description = "A map of IAM policy statements for custom permission usage" + type = list(object({ + sid = optional(string) + actions = optional(list(string)) + not_actions = optional(list(string)) + effect = optional(string) + resources = optional(list(string)) + not_resources = optional(list(string)) + principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + not_principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + condition = optional(list(object({ + test = string + values = list(string) + variable = string + }))) + })) + default = null +} + +variable "iam_role_tags" { + description = "A map of additional tags to add to the IAM role created" + type = map(string) + default = {} +} + +variable "iam_role_use_name_prefix" { + description = "Determines whether the IAM role name (`iam_role_name`) is used as a prefix" + type = bool + default = true +} From aad4a2bba324ef9668bc31eb47f60b53f9878223 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:31:24 +0100 Subject: [PATCH 102/118] feat(ecs-service): add inputs ig-ip --- infrastructure/modules/ecs-service/README.md | 9 ++ infrastructure/modules/ecs-service/main.tf | 95 ++++++++++--------- .../modules/ecs-service/variables.tf | 54 +++++++++++ 3 files changed, 115 insertions(+), 43 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 118f9d3d..cc60883b 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -84,6 +84,15 @@ No resources. | [iam\_role\_tags](#input\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | | [iam\_role\_use\_name\_prefix](#input\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`iam_role_name`) is used as a prefix | `bool` | `true` | 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 | +| [ignore\_task\_definition\_changes](#input\_ignore\_task\_definition\_changes) | Whether changes to service `task_definition` changes should be ignored | `bool` | `false` | no | +| [infrastructure\_iam\_role\_arn](#input\_infrastructure\_iam\_role\_arn) | Existing IAM role ARN | `string` | `null` | no | +| [infrastructure\_iam\_role\_description](#input\_infrastructure\_iam\_role\_description) | Description of the role | `string` | `null` | no | +| [infrastructure\_iam\_role\_name](#input\_infrastructure\_iam\_role\_name) | Name to use on IAM role created | `string` | `null` | no | +| [infrastructure\_iam\_role\_path](#input\_infrastructure\_iam\_role\_path) | IAM role path | `string` | `null` | no | +| [infrastructure\_iam\_role\_permissions\_boundary](#input\_infrastructure\_iam\_role\_permissions\_boundary) | ARN of the policy that is used to set the permissions boundary for the IAM role | `string` | `null` | no | +| [infrastructure\_iam\_role\_tags](#input\_infrastructure\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | +| [infrastructure\_iam\_role\_use\_name\_prefix](#input\_infrastructure\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`iam_role_name`) is used as a prefix | `bool` | `true` | no | +| [ipc\_mode](#input\_ipc\_mode) | IPC resource namespace to be used for the containers in the task The valid values are `host`, `task`, and `none` | `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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index b3ad4100..ffeb7d57 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -6,47 +6,56 @@ module "ecs_service" { name = module.this.name tags = module.this.tags - alarms = var.alarms - assign_public_ip = var.assign_public_ip - autoscaling_max_capacity = var.autoscaling_max_capacity - autoscaling_min_capacity = var.autoscaling_min_capacity - autoscaling_policies = var.autoscaling_policies - autoscaling_scheduled_actions = var.autoscaling_scheduled_actions - autoscaling_suspended_state = var.autoscaling_suspended_state - availability_zone_rebalancing = var.availability_zone_rebalancing - capacity_provider_strategy = var.capacity_provider_strategy - cluster_arn = var.cluster_arn - container_definitions = var.container_definitions - cpu = var.cpu - create_iam_role = var.create_iam_role - create_infrastructure_iam_role = var.create_infrastructure_iam_role - create_security_group = var.create_security_group - create_service = var.create_service - create_task_definition = var.create_task_definition - create_task_exec_iam_role = var.create_task_exec_iam_role - create_task_exec_policy = var.create_task_exec_policy - create_tasks_iam_role = var.create_tasks_iam_role - deployment_circuit_breaker = var.deployment_circuit_breaker - deployment_configuration = var.deployment_configuration - deployment_controller = var.deployment_controller - deployment_maximum_percent = var.deployment_maximum_percent - deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent - desired_count = var.desired_count - enable_autoscaling = var.enable_autoscaling - enable_ecs_managed_tags = var.enable_ecs_managed_tags - enable_execute_command = var.enable_execute_command - enable_fault_injection = var.enable_fault_injection - ephemeral_storage = var.ephemeral_storage - external_id = var.external_id - force_delete = var.force_delete - force_new_deployment = var.force_new_deployment - health_check_grace_period_seconds = var.health_check_grace_period_seconds - iam_role_arn = var.iam_role_arn - iam_role_description = var.iam_role_description - iam_role_name = var.iam_role_name - iam_role_path = var.iam_role_path - iam_role_permissions_boundary = var.iam_role_permissions_boundary - iam_role_statements = var.iam_role_statements - iam_role_tags = var.iam_role_tags - iam_role_use_name_prefix = var.iam_role_use_name_prefix + alarms = var.alarms + assign_public_ip = var.assign_public_ip + autoscaling_max_capacity = var.autoscaling_max_capacity + autoscaling_min_capacity = var.autoscaling_min_capacity + autoscaling_policies = var.autoscaling_policies + autoscaling_scheduled_actions = var.autoscaling_scheduled_actions + autoscaling_suspended_state = var.autoscaling_suspended_state + availability_zone_rebalancing = var.availability_zone_rebalancing + capacity_provider_strategy = var.capacity_provider_strategy + cluster_arn = var.cluster_arn + container_definitions = var.container_definitions + cpu = var.cpu + create_iam_role = var.create_iam_role + create_infrastructure_iam_role = var.create_infrastructure_iam_role + create_security_group = var.create_security_group + create_service = var.create_service + create_task_definition = var.create_task_definition + create_task_exec_iam_role = var.create_task_exec_iam_role + create_task_exec_policy = var.create_task_exec_policy + create_tasks_iam_role = var.create_tasks_iam_role + deployment_circuit_breaker = var.deployment_circuit_breaker + deployment_configuration = var.deployment_configuration + deployment_controller = var.deployment_controller + deployment_maximum_percent = var.deployment_maximum_percent + deployment_minimum_healthy_percent = var.deployment_minimum_healthy_percent + desired_count = var.desired_count + enable_autoscaling = var.enable_autoscaling + enable_ecs_managed_tags = var.enable_ecs_managed_tags + enable_execute_command = var.enable_execute_command + enable_fault_injection = var.enable_fault_injection + ephemeral_storage = var.ephemeral_storage + external_id = var.external_id + force_delete = var.force_delete + force_new_deployment = var.force_new_deployment + health_check_grace_period_seconds = var.health_check_grace_period_seconds + iam_role_arn = var.iam_role_arn + iam_role_description = var.iam_role_description + iam_role_name = var.iam_role_name + iam_role_path = var.iam_role_path + iam_role_permissions_boundary = var.iam_role_permissions_boundary + iam_role_statements = var.iam_role_statements + iam_role_tags = var.iam_role_tags + iam_role_use_name_prefix = var.iam_role_use_name_prefix + ignore_task_definition_changes = var.ignore_task_definition_changes + infrastructure_iam_role_arn = var.infrastructure_iam_role_arn + infrastructure_iam_role_description = var.infrastructure_iam_role_description + infrastructure_iam_role_name = var.infrastructure_iam_role_name + infrastructure_iam_role_path = var.infrastructure_iam_role_path + infrastructure_iam_role_permissions_boundary = var.infrastructure_iam_role_permissions_boundary + infrastructure_iam_role_tags = var.infrastructure_iam_role_tags + infrastructure_iam_role_use_name_prefix = var.infrastructure_iam_role_use_name_prefix + ipc_mode = var.ipc_mode } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 42c40a0f..bd633040 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -606,3 +606,57 @@ variable "iam_role_use_name_prefix" { type = bool default = true } + +variable "ignore_task_definition_changes" { + description = "Whether changes to service `task_definition` changes should be ignored" + type = bool + default = false +} + +variable "infrastructure_iam_role_arn" { + description = "Existing IAM role ARN" + type = string + default = null +} + +variable "infrastructure_iam_role_description" { + description = "Description of the role" + type = string + default = null +} + +variable "infrastructure_iam_role_name" { + description = "Name to use on IAM role created" + type = string + default = null +} + +variable "infrastructure_iam_role_path" { + description = "IAM role path" + type = string + default = null +} + +variable "infrastructure_iam_role_permissions_boundary" { + description = "ARN of the policy that is used to set the permissions boundary for the IAM role" + type = string + default = null +} + +variable "infrastructure_iam_role_tags" { + description = "A map of additional tags to add to the IAM role created" + type = map(string) + default = {} +} + +variable "infrastructure_iam_role_use_name_prefix" { + description = "Determines whether the IAM role name (`iam_role_name`) is used as a prefix" + type = bool + default = true +} + +variable "ipc_mode" { + description = "IPC resource namespace to be used for the containers in the task The valid values are `host`, `task`, and `none`" + type = string + default = null +} From 27d22880250a03421b0688111b3df1d16efe30d2 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:51:38 +0100 Subject: [PATCH 103/118] feat(ecs-service): add inputs l-p --- infrastructure/modules/ecs-service/README.md | 10 +++ infrastructure/modules/ecs-service/main.tf | 10 +++ .../modules/ecs-service/variables.tf | 81 +++++++++++++++++++ 3 files changed, 101 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index cc60883b..fe921665 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -97,10 +97,20 @@ No resources. | [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 | +| [launch\_type](#input\_launch\_type) | Launch type on which to run your service. The valid values are `EC2`, `FARGATE`, and `EXTERNAL`. Defaults to `FARGATE` | `string` | `"FARGATE"` | no | +| [load\_balancer](#input\_load\_balancer) | Configuration block for load balancers |
map(object({
container_name = string
container_port = number
elb_name = optional(string)
target_group_arn = optional(string)
advanced_configuration = optional(object({
alternate_target_group_arn = string
production_listener_rule = string # Should be optional but bug in provider
role_arn = optional(string)
test_listener_rule = optional(string)
}))
}))
| `null` | no | +| [memory](#input\_memory) | Amount of memory (in MiB) used by the task. If the `requires_compatibilities` is `FARGATE` this field is required | `number` | `2048` | 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 | +| [network\_mode](#input\_network\_mode) | Docker networking mode to use for the containers in the task. Valid values are `none`, `bridge`, `awsvpc`, and `host` | `string` | `"awsvpc"` | no | | [on\_off\_pattern](#input\_on\_off\_pattern) | Used to turn resources on and off based on a time pattern | `string` | `"n/a"` | no | +| [ordered\_placement\_strategy](#input\_ordered\_placement\_strategy) | Service level strategy rules that are taken into consideration during task placement. List from top to bottom in order of precedence |
list(object({
field = optional(string)
type = string
}))
| `null` | no | | [owner](#input\_owner) | The name and or NHS.net email address of the service owner | `string` | `"None"` | no | +| [pid\_mode](#input\_pid\_mode) | Process namespace to use for the containers in the task. The valid values are `host` and `task` | `string` | `null` | no | +| [placement\_constraints](#input\_placement\_constraints) | Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the service, see `task_definition_placement_constraints` for setting at the task definition |
map(object({
expression = optional(string)
type = string
}))
| `null` | no | +| [platform\_version](#input\_platform\_version) | Platform version on which to run your service. Only applicable for `launch_type` set to `FARGATE`. Defaults to `LATEST` | `string` | `null` | 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 | +| [propagate\_tags](#input\_propagate\_tags) | Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION` | `string` | `null` | no | +| [proxy\_configuration](#input\_proxy\_configuration) | Configuration block for the App Mesh proxy |
object({
container_name = string
properties = optional(map(string))
type = optional(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 | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index ffeb7d57..ca8c8a3f 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -58,4 +58,14 @@ module "ecs_service" { infrastructure_iam_role_tags = var.infrastructure_iam_role_tags infrastructure_iam_role_use_name_prefix = var.infrastructure_iam_role_use_name_prefix ipc_mode = var.ipc_mode + launch_type = var.launch_type + load_balancer = var.load_balancer + memory = var.memory + network_mode = var.network_mode + ordered_placement_strategy = var.ordered_placement_strategy + pid_mode = var.pid_mode + placement_constraints = var.placement_constraints + platform_version = var.platform_version + propagate_tags = var.propagate_tags + proxy_configuration = var.proxy_configuration } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index bd633040..adaab9f7 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -660,3 +660,84 @@ variable "ipc_mode" { type = string default = null } + +variable "launch_type" { + description = "Launch type on which to run your service. The valid values are `EC2`, `FARGATE`, and `EXTERNAL`. Defaults to `FARGATE`" + type = string + default = "FARGATE" +} + +variable "load_balancer" { + description = "Configuration block for load balancers" + type = map(object({ + container_name = string + container_port = number + elb_name = optional(string) + target_group_arn = optional(string) + advanced_configuration = optional(object({ + alternate_target_group_arn = string + production_listener_rule = string # Should be optional but bug in provider + role_arn = optional(string) + test_listener_rule = optional(string) + })) + })) + default = null +} + +variable "memory" { + description = "Amount of memory (in MiB) used by the task. If the `requires_compatibilities` is `FARGATE` this field is required" + type = number + default = 2048 +} + +variable "network_mode" { + description = "Docker networking mode to use for the containers in the task. Valid values are `none`, `bridge`, `awsvpc`, and `host`" + type = string + default = "awsvpc" +} + +variable "ordered_placement_strategy" { + description = "Service level strategy rules that are taken into consideration during task placement. List from top to bottom in order of precedence" + type = list(object({ + field = optional(string) + type = string + })) + default = null +} + +variable "pid_mode" { + description = "Process namespace to use for the containers in the task. The valid values are `host` and `task`" + type = string + default = null +} + +variable "placement_constraints" { + description = "Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the service, see `task_definition_placement_constraints` for setting at the task definition" + type = map(object({ + expression = optional(string) + type = string + })) + default = null +} + +variable "platform_version" { + description = "Platform version on which to run your service. Only applicable for `launch_type` set to `FARGATE`. Defaults to `LATEST`" + type = string + default = null +} + +variable "propagate_tags" { + description = "Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION`" + type = string + default = null +} + +variable "proxy_configuration" { + description = "Configuration block for the App Mesh proxy" + type = object({ + container_name = string + properties = optional(map(string)) + type = optional(string) + }) + default = null +} From 84d5846e8a1211e926939d6a374c7a286cca7b34 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 10:58:15 +0100 Subject: [PATCH 104/118] feat(ecs-service): add inputs r-sc --- infrastructure/modules/ecs-service/README.md | 4 +++ infrastructure/modules/ecs-service/main.tf | 4 +++ .../modules/ecs-service/variables.tf | 30 +++++++++++++++++++ 3 files changed, 38 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index fe921665..167e2dde 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -114,6 +114,10 @@ No resources. | [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 | +| [requires\_compatibilities](#input\_requires\_compatibilities) | Set of launch types required by the task. The valid values are `EC2`, `FARGATE`, `EXTERNAL`, and `MANAGED_INSTANCES` | `list(string)` |
[
"FARGATE"
]
| no | +| [runtime\_platform](#input\_runtime\_platform) | Configuration block for `runtime_platform` that containers in your task may use |
object({
cpu_architecture = optional(string, "X86_64")
operating_system_family = optional(string, "LINUX")
})
| `{}` | no | +| [scale](#input\_scale) | A floating-point percentage of the desired number of tasks to place and keep running in the task set |
object({
unit = optional(string)
value = optional(number)
})
| `null` | no | +| [scheduling\_strategy](#input\_scheduling\_strategy) | Scheduling strategy to use for the service. The valid values are `REPLICA` and `DAEMON`. Defaults to `REPLICA` | `string` | `null` | 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 | | [stack](#input\_stack) | ID element. The name of the stack/component, e.g. `database`, `web`, `waf`, `eks` | `string` | `null` | no | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index ca8c8a3f..5d51a407 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -68,4 +68,8 @@ module "ecs_service" { platform_version = var.platform_version propagate_tags = var.propagate_tags proxy_configuration = var.proxy_configuration + requires_compatibilities = var.requires_compatibilities + runtime_platform = var.runtime_platform + scale = var.scale + scheduling_strategy = var.scheduling_strategy } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index adaab9f7..9d694057 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -741,3 +741,33 @@ variable "proxy_configuration" { }) default = null } + +variable "requires_compatibilities" { + description = "Set of launch types required by the task. The valid values are `EC2`, `FARGATE`, `EXTERNAL`, and `MANAGED_INSTANCES`" + type = list(string) + default = ["FARGATE"] +} + +variable "runtime_platform" { + description = "Configuration block for `runtime_platform` that containers in your task may use" + type = object({ + cpu_architecture = optional(string, "X86_64") + operating_system_family = optional(string, "LINUX") + }) + default = {} +} + +variable "scale" { + description = "A floating-point percentage of the desired number of tasks to place and keep running in the task set" + type = object({ + unit = optional(string) + value = optional(number) + }) + default = null +} + +variable "scheduling_strategy" { + description = "Scheduling strategy to use for the service. The valid values are `REPLICA` and `DAEMON`. Defaults to `REPLICA`" + type = string + default = null +} From 0918875ece92774eab054fd8a7a2c5d3ecf18456 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 11:56:42 +0100 Subject: [PATCH 105/118] feat(ecs-service): add inputs s --- infrastructure/modules/ecs-service/README.md | 13 ++ infrastructure/modules/ecs-service/main.tf | 13 ++ .../modules/ecs-service/variables.tf | 148 ++++++++++++++++++ 3 files changed, 174 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 167e2dde..1b3b70ad 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -118,9 +118,22 @@ No resources. | [runtime\_platform](#input\_runtime\_platform) | Configuration block for `runtime_platform` that containers in your task may use |
object({
cpu_architecture = optional(string, "X86_64")
operating_system_family = optional(string, "LINUX")
})
| `{}` | no | | [scale](#input\_scale) | A floating-point percentage of the desired number of tasks to place and keep running in the task set |
object({
unit = optional(string)
value = optional(number)
})
| `null` | no | | [scheduling\_strategy](#input\_scheduling\_strategy) | Scheduling strategy to use for the service. The valid values are `REPLICA` and `DAEMON`. Defaults to `REPLICA` | `string` | `null` | no | +| [security\_group\_description](#input\_security\_group\_description) | Description of the security group created | `string` | `null` | no | +| [security\_group\_egress\_rules](#input\_security\_group\_egress\_rules) | Security group egress rules to add to the security group created |
map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
from_port = optional(string)
ip_protocol = optional(string, "tcp")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
to_port = optional(string)
}))
| `{}` | no | +| [security\_group\_ids](#input\_security\_group\_ids) | List of security groups to associate with the task or service | `list(string)` | `[]` | no | +| [security\_group\_ingress\_rules](#input\_security\_group\_ingress\_rules) | Security group ingress rules to add to the security group created |
map(object({
name = optional(string)
cidr_ipv4 = optional(string)
cidr_ipv6 = optional(string)
description = optional(string)
from_port = optional(string)
ip_protocol = optional(string, "tcp")
prefix_list_id = optional(string)
referenced_security_group_id = optional(string)
tags = optional(map(string), {})
to_port = optional(string)
}))
| `{}` | no | +| [security\_group\_name](#input\_security\_group\_name) | Name to use on security group created | `string` | `null` | no | +| [security\_group\_tags](#input\_security\_group\_tags) | A map of additional tags to add to the security group created | `map(string)` | `{}` | no | +| [security\_group\_use\_name\_prefix](#input\_security\_group\_use\_name\_prefix) | Determines whether the security group name (`security_group_name`) is used as a prefix | `bool` | `true` | 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 | +| [service\_connect\_configuration](#input\_service\_connect\_configuration) | The ECS Service Connect configuration for this service to discover and connect to services, and be discovered by, and connected from, other services within a namespace |
object({
enabled = optional(bool, true)
access_log_configuration = optional(object({
format = string
include_query_parameters = optional(string)
}))
log_configuration = optional(object({
log_driver = string
options = optional(map(string))
secret_option = optional(list(object({
name = string
value_from = string
})))
}))
namespace = optional(string)
service = optional(list(object({
client_alias = optional(object({
dns_name = optional(string)
port = number
test_traffic_rules = optional(list(object({
header = optional(object({
name = string
value = object({
exact = string
})
}))
})))
}))
discovery_name = optional(string)
ingress_port_override = optional(number)
port_name = string
timeout = optional(object({
idle_timeout_seconds = optional(number)
per_request_timeout_seconds = optional(number)
}))
tls = optional(object({
issuer_cert_authority = object({
aws_pca_authority_arn = string
})
kms_key = optional(string)
role_arn = optional(string)
}))
})))
})
| `null` | no | +| [service\_registries](#input\_service\_registries) | Service discovery registries for the service |
object({
container_name = optional(string)
container_port = optional(number)
port = optional(number)
registry_arn = string
})
| `null` | no | +| [service\_tags](#input\_service\_tags) | A map of additional tags to add to the service | `map(string)` | `{}` | no | +| [sigint\_rollback](#input\_sigint\_rollback) | Whether to enable graceful termination of deployments using SIGINT signals. Only applicable when using ECS deployment controller and requires wait\_for\_steady\_state = true. Default is false | `bool` | `null` | no | +| [skip\_destroy](#input\_skip\_destroy) | If true, the task is not deleted when the service is deleted | `bool` | `null` | 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 subnets to associate with the task or service | `list(string)` | `[]` | no | | [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 this module path. | `string` | `null` | no | diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 5d51a407..6d69af18 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -72,4 +72,17 @@ module "ecs_service" { runtime_platform = var.runtime_platform scale = var.scale scheduling_strategy = var.scheduling_strategy + security_group_description = var.security_group_description + security_group_egress_rules = var.security_group_egress_rules + security_group_ids = var.security_group_ids + security_group_ingress_rules = var.security_group_ingress_rules + security_group_name = var.security_group_name + security_group_tags = var.security_group_tags + security_group_use_name_prefix = var.security_group_use_name_prefix + service_connect_configuration = var.service_connect_configuration + service_registries = var.service_registries + service_tags = var.service_tags + sigint_rollback = var.sigint_rollback + skip_destroy = var.skip_destroy + subnet_ids = var.subnet_ids } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 9d694057..f048b8f5 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -771,3 +771,151 @@ variable "scheduling_strategy" { type = string default = null } + +variable "security_group_description" { + description = "Description of the security group created" + type = string + default = null +} + +variable "security_group_egress_rules" { + description = "Security group egress rules to add to the security group created" + type = map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + from_port = optional(string) + ip_protocol = optional(string, "tcp") + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + to_port = optional(string) + })) + default = {} +} + +variable "security_group_ids" { + description = "List of security groups to associate with the task or service" + type = list(string) + default = [] +} + +variable "security_group_ingress_rules" { + description = "Security group ingress rules to add to the security group created" + type = map(object({ + name = optional(string) + cidr_ipv4 = optional(string) + cidr_ipv6 = optional(string) + description = optional(string) + from_port = optional(string) + ip_protocol = optional(string, "tcp") + prefix_list_id = optional(string) + referenced_security_group_id = optional(string) + tags = optional(map(string), {}) + to_port = optional(string) + })) + default = {} +} + +variable "security_group_name" { + description = "Name to use on security group created" + type = string + default = null +} + +variable "security_group_tags" { + description = "A map of additional tags to add to the security group created" + type = map(string) + default = {} +} + +variable "security_group_use_name_prefix" { + description = "Determines whether the security group name (`security_group_name`) is used as a prefix" + type = bool + default = true +} + +variable "service_connect_configuration" { + description = "The ECS Service Connect configuration for this service to discover and connect to services, and be discovered by, and connected from, other services within a namespace" + type = object({ + enabled = optional(bool, true) + access_log_configuration = optional(object({ + format = string + include_query_parameters = optional(string) + })) + log_configuration = optional(object({ + log_driver = string + options = optional(map(string)) + secret_option = optional(list(object({ + name = string + value_from = string + }))) + })) + namespace = optional(string) + service = optional(list(object({ + client_alias = optional(object({ + dns_name = optional(string) + port = number + test_traffic_rules = optional(list(object({ + header = optional(object({ + name = string + value = object({ + exact = string + }) + })) + }))) + })) + discovery_name = optional(string) + ingress_port_override = optional(number) + port_name = string + timeout = optional(object({ + idle_timeout_seconds = optional(number) + per_request_timeout_seconds = optional(number) + })) + tls = optional(object({ + issuer_cert_authority = object({ + aws_pca_authority_arn = string + }) + kms_key = optional(string) + role_arn = optional(string) + })) + }))) + }) + default = null +} + +variable "service_registries" { + description = "Service discovery registries for the service" + type = object({ + container_name = optional(string) + container_port = optional(number) + port = optional(number) + registry_arn = string + }) + default = null +} + +variable "service_tags" { + description = "A map of additional tags to add to the service" + type = map(string) + default = {} +} + +variable "sigint_rollback" { + description = "Whether to enable graceful termination of deployments using SIGINT signals. Only applicable when using ECS deployment controller and requires wait_for_steady_state = true. Default is false" + type = bool + default = null +} + +variable "skip_destroy" { + description = "If true, the task is not deleted when the service is deleted" + type = bool + default = null +} + +variable "subnet_ids" { + description = "List of subnets to associate with the task or service" + type = list(string) + default = [] +} From f0ae39a5ac3dc7ea3391da9d12540547f44a4615 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:11:27 +0100 Subject: [PATCH 106/118] feat(ecs-service): add inputs task_* --- infrastructure/modules/ecs-service/README.md | 16 +++ infrastructure/modules/ecs-service/main.tf | 17 +++ .../modules/ecs-service/variables.tf | 119 ++++++++++++++++++ 3 files changed, 152 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 1b3b70ad..5602a126 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -136,6 +136,22 @@ No resources. | [subnet\_ids](#input\_subnet\_ids) | List of subnets to associate with the task or service | `list(string)` | `[]` | no | | [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 | +| [task\_definition\_arn](#input\_task\_definition\_arn) | Existing task definition ARN. Required when `create_task_definition` is `false` | `string` | `null` | no | +| [task\_definition\_placement\_constraints](#input\_task\_definition\_placement\_constraints) | Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the task definition, see `placement_constraints` for setting at the service |
map(object({
expression = optional(string)
type = string
}))
| `null` | no | +| [task\_exec\_iam\_policy\_path](#input\_task\_exec\_iam\_policy\_path) | Path for the iam role | `string` | `null` | no | +| [task\_exec\_iam\_role\_arn](#input\_task\_exec\_iam\_role\_arn) | Existing IAM role ARN | `string` | `null` | no | +| [task\_exec\_iam\_role\_description](#input\_task\_exec\_iam\_role\_description) | Description of the role | `string` | `null` | no | +| [task\_exec\_iam\_role\_max\_session\_duration](#input\_task\_exec\_iam\_role\_max\_session\_duration) | Maximum session duration (in seconds) for ECS task execution role. Default is 3600. | `number` | `null` | no | +| [task\_exec\_iam\_role\_name](#input\_task\_exec\_iam\_role\_name) | Name to use on IAM role created | `string` | `null` | no | +| [task\_exec\_iam\_role\_path](#input\_task\_exec\_iam\_role\_path) | IAM role path | `string` | `null` | no | +| [task\_exec\_iam\_role\_permissions\_boundary](#input\_task\_exec\_iam\_role\_permissions\_boundary) | ARN of the policy that is used to set the permissions boundary for the IAM role | `string` | `null` | no | +| [task\_exec\_iam\_role\_policies](#input\_task\_exec\_iam\_role\_policies) | Map of IAM role policy ARNs to attach to the IAM role | `map(string)` | `null` | no | +| [task\_exec\_iam\_role\_tags](#input\_task\_exec\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | +| [task\_exec\_iam\_role\_use\_name\_prefix](#input\_task\_exec\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`task_exec_iam_role_name`) is used as a prefix | `bool` | `true` | no | +| [task\_exec\_iam\_statements](#input\_task\_exec\_iam\_statements) | A map of IAM policy statements for custom permission usage |
list(object({
sid = optional(string)
actions = optional(list(string))
not_actions = optional(list(string))
effect = optional(string)
resources = optional(list(string))
not_resources = optional(list(string))
principals = optional(list(object({
type = string
identifiers = list(string)
})))
not_principals = optional(list(object({
type = string
identifiers = list(string)
})))
condition = optional(list(object({
test = string
values = list(string)
variable = string
})))
}))
| `null` | no | +| [task\_exec\_secret\_arns](#input\_task\_exec\_secret\_arns) | List of SecretsManager secret ARNs the task execution role will be permitted to get/read | `list(string)` | `[]` | no | +| [task\_exec\_ssm\_param\_arns](#input\_task\_exec\_ssm\_param\_arns) | List of SSM parameter ARNs the task execution role will be permitted to get/read | `list(string)` | `[]` | no | +| [task\_tags](#input\_task\_tags) | A map of additional tags to add to the task definition/set created | `map(string)` | `{}` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | 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/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 6d69af18..9cfb37fd 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -85,4 +85,21 @@ module "ecs_service" { sigint_rollback = var.sigint_rollback skip_destroy = var.skip_destroy subnet_ids = var.subnet_ids + task_definition_arn = var.task_definition_arn + task_definition_placement_constraints = var.task_definition_placement_constraints + task_exec_iam_policy_path = var.task_exec_iam_policy_path + task_exec_iam_role_arn = var.task_exec_iam_role_arn + task_exec_iam_role_description = var.task_exec_iam_role_description + task_exec_iam_role_max_session_duration = var.task_exec_iam_role_max_session_duration + task_exec_iam_role_name = var.task_exec_iam_role_name + task_exec_iam_role_path = var.task_exec_iam_role_path + task_exec_iam_role_permissions_boundary = var.task_exec_iam_role_permissions_boundary + task_exec_iam_role_policies = var.task_exec_iam_role_policies + task_exec_iam_role_tags = var.task_exec_iam_role_tags + task_exec_iam_role_use_name_prefix = var.task_exec_iam_role_use_name_prefix + task_exec_iam_statements = var.task_exec_iam_statements + task_exec_secret_arns = var.task_exec_secret_arns + task_exec_ssm_param_arns = var.task_exec_ssm_param_arns + task_tags = var.task_tags + } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index f048b8f5..b85608fc 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -919,3 +919,122 @@ variable "subnet_ids" { type = list(string) default = [] } + +variable "task_definition_arn" { + description = "Existing task definition ARN. Required when `create_task_definition` is `false`" + type = string + default = null +} + +variable "task_definition_placement_constraints" { + description = "Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the task definition, see `placement_constraints` for setting at the service" + type = map(object({ + expression = optional(string) + type = string + })) + default = null +} + +variable "task_exec_iam_policy_path" { + description = "Path for the iam role" + type = string + default = null +} + +variable "task_exec_iam_role_arn" { + description = "Existing IAM role ARN" + type = string + default = null +} + +variable "task_exec_iam_role_description" { + description = "Description of the role" + type = string + default = null +} + +variable "task_exec_iam_role_max_session_duration" { + description = "Maximum session duration (in seconds) for ECS task execution role. Default is 3600." + type = number + default = null +} + +variable "task_exec_iam_role_name" { + description = "Name to use on IAM role created" + type = string + default = null +} + +variable "task_exec_iam_role_path" { + description = "IAM role path" + type = string + default = null +} + +variable "task_exec_iam_role_permissions_boundary" { + description = "ARN of the policy that is used to set the permissions boundary for the IAM role" + type = string + default = null +} + +variable "task_exec_iam_role_policies" { + description = "Map of IAM role policy ARNs to attach to the IAM role" + type = map(string) + default = null +} + +variable "task_exec_iam_role_tags" { + description = "A map of additional tags to add to the IAM role created" + type = map(string) + default = {} +} + +variable "task_exec_iam_role_use_name_prefix" { + description = "Determines whether the IAM role name (`task_exec_iam_role_name`) is used as a prefix" + type = bool + default = true +} + +variable "task_exec_iam_statements" { + description = "A map of IAM policy statements for custom permission usage" + type = list(object({ + sid = optional(string) + actions = optional(list(string)) + not_actions = optional(list(string)) + effect = optional(string) + resources = optional(list(string)) + not_resources = optional(list(string)) + principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + not_principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + condition = optional(list(object({ + test = string + values = list(string) + variable = string + }))) + })) + default = null +} + +variable "task_exec_secret_arns" { + description = "List of SecretsManager secret ARNs the task execution role will be permitted to get/read" + type = list(string) + default = [] +} + +variable "task_exec_ssm_param_arns" { + description = "List of SSM parameter ARNs the task execution role will be permitted to get/read" + type = list(string) + default = [] +} + +variable "task_tags" { + description = "A map of additional tags to add to the task definition/set created" + type = map(string) + default = {} +} From 5af034ab5ca30d5fde3400ef72a3391d8a31f0dd Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:26:51 +0100 Subject: [PATCH 107/118] feat(ecs-service): add inputs tasks_* --- infrastructure/modules/ecs-service/README.md | 10 +++ infrastructure/modules/ecs-service/main.tf | 11 ++- .../modules/ecs-service/variables.tf | 80 +++++++++++++++++++ 3 files changed, 100 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 5602a126..3c7f87f9 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -152,6 +152,16 @@ No resources. | [task\_exec\_secret\_arns](#input\_task\_exec\_secret\_arns) | List of SecretsManager secret ARNs the task execution role will be permitted to get/read | `list(string)` | `[]` | no | | [task\_exec\_ssm\_param\_arns](#input\_task\_exec\_ssm\_param\_arns) | List of SSM parameter ARNs the task execution role will be permitted to get/read | `list(string)` | `[]` | no | | [task\_tags](#input\_task\_tags) | A map of additional tags to add to the task definition/set created | `map(string)` | `{}` | no | +| [tasks\_iam\_role\_arn](#input\_tasks\_iam\_role\_arn) | Existing IAM role ARN | `string` | `null` | no | +| [tasks\_iam\_role\_description](#input\_tasks\_iam\_role\_description) | Description of the role | `string` | `null` | no | +| [tasks\_iam\_role\_max\_session\_duration](#input\_tasks\_iam\_role\_max\_session\_duration) | Maximum session duration (in seconds) for ECS tasks role. Default is 3600. | `number` | `null` | no | +| [tasks\_iam\_role\_name](#input\_tasks\_iam\_role\_name) | Name to use on IAM role created | `string` | `null` | no | +| [tasks\_iam\_role\_path](#input\_tasks\_iam\_role\_path) | IAM role path | `string` | `null` | no | +| [tasks\_iam\_role\_permissions\_boundary](#input\_tasks\_iam\_role\_permissions\_boundary) | ARN of the policy that is used to set the permissions boundary for the IAM role | `string` | `null` | no | +| [tasks\_iam\_role\_policies](#input\_tasks\_iam\_role\_policies) | Map of additional IAM role policy ARNs to attach to the IAM role | `map(string)` | `null` | no | +| [tasks\_iam\_role\_statements](#input\_tasks\_iam\_role\_statements) | A map of IAM policy statements for custom permission usage |
list(object({
sid = optional(string)
actions = optional(list(string))
not_actions = optional(list(string))
effect = optional(string)
resources = optional(list(string))
not_resources = optional(list(string))
principals = optional(list(object({
type = string
identifiers = list(string)
})))
not_principals = optional(list(object({
type = string
identifiers = list(string)
})))
condition = optional(list(object({
test = string
values = list(string)
variable = string
})))
}))
| `null` | no | +| [tasks\_iam\_role\_tags](#input\_tasks\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | +| [tasks\_iam\_role\_use\_name\_prefix](#input\_tasks\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`tasks_iam_role_name`) is used as a prefix | `bool` | `true` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | 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/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 9cfb37fd..0fd21b71 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -101,5 +101,14 @@ module "ecs_service" { task_exec_secret_arns = var.task_exec_secret_arns task_exec_ssm_param_arns = var.task_exec_ssm_param_arns task_tags = var.task_tags - + tasks_iam_role_arn = var.tasks_iam_role_arn + tasks_iam_role_description = var.tasks_iam_role_description + tasks_iam_role_max_session_duration = var.tasks_iam_role_max_session_duration + tasks_iam_role_name = var.tasks_iam_role_name + tasks_iam_role_path = var.tasks_iam_role_path + tasks_iam_role_permissions_boundary = var.tasks_iam_role_permissions_boundary + tasks_iam_role_policies = var.tasks_iam_role_policies + tasks_iam_role_statements = var.tasks_iam_role_statements + tasks_iam_role_tags = var.tasks_iam_role_tags + tasks_iam_role_use_name_prefix = var.tasks_iam_role_use_name_prefix } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index b85608fc..2007b9aa 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -1038,3 +1038,83 @@ variable "task_tags" { type = map(string) default = {} } + +variable "tasks_iam_role_arn" { + description = "Existing IAM role ARN" + type = string + default = null +} + +variable "tasks_iam_role_description" { + description = "Description of the role" + type = string + default = null +} + +variable "tasks_iam_role_max_session_duration" { + description = "Maximum session duration (in seconds) for ECS tasks role. Default is 3600." + type = number + default = null +} + +variable "tasks_iam_role_name" { + description = "Name to use on IAM role created" + type = string + default = null +} + +variable "tasks_iam_role_path" { + description = "IAM role path" + type = string + default = null +} + +variable "tasks_iam_role_permissions_boundary" { + description = "ARN of the policy that is used to set the permissions boundary for the IAM role" + type = string + default = null +} + +variable "tasks_iam_role_policies" { + description = "Map of additional IAM role policy ARNs to attach to the IAM role" + type = map(string) + default = null +} + +variable "tasks_iam_role_statements" { + description = "A map of IAM policy statements for custom permission usage" + type = list(object({ + sid = optional(string) + actions = optional(list(string)) + not_actions = optional(list(string)) + effect = optional(string) + resources = optional(list(string)) + not_resources = optional(list(string)) + principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + not_principals = optional(list(object({ + type = string + identifiers = list(string) + }))) + condition = optional(list(object({ + test = string + values = list(string) + variable = string + }))) + })) + default = null +} + +variable "tasks_iam_role_tags" { + description = "A map of additional tags to add to the IAM role created" + type = map(string) + default = {} +} + +variable "tasks_iam_role_use_name_prefix" { + description = "Determines whether the IAM role name (`tasks_iam_role_name`) is used as a prefix" + type = bool + default = true +} From 76ed81a6d1b60d61916cac4ea136ed53f559e549 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:34:04 +0100 Subject: [PATCH 108/118] feat(ecs-service): add inputs ti-z --- infrastructure/modules/ecs-service/README.md | 10 ++ infrastructure/modules/ecs-service/main.tf | 10 ++ .../modules/ecs-service/variables.tf | 115 ++++++++++++++++++ 3 files changed, 135 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 3c7f87f9..7a8e2a20 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -163,7 +163,17 @@ No resources. | [tasks\_iam\_role\_tags](#input\_tasks\_iam\_role\_tags) | A map of additional tags to add to the IAM role created | `map(string)` | `{}` | no | | [tasks\_iam\_role\_use\_name\_prefix](#input\_tasks\_iam\_role\_use\_name\_prefix) | Determines whether the IAM role name (`tasks_iam_role_name`) is used as a prefix | `bool` | `true` | no | | [terraform\_source](#input\_terraform\_source) | Source location to record in the Terraform\_source tag. Defaults to this module path. | `string` | `null` | no | +| [timeouts](#input\_timeouts) | Create, update, and delete timeout configurations for the service |
object({
create = optional(string)
delete = optional(string)
update = optional(string)
})
| `null` | no | | [tool](#input\_tool) | The tool used to deploy the resource | `string` | `"Terraform"` | no | +| [track\_latest](#input\_track\_latest) | Whether should track latest `ACTIVE` task definition on AWS or the one created with the resource stored in state. Useful in the event the task definition is modified outside of this resource | `bool` | `true` | no | +| [triggers](#input\_triggers) | Map of arbitrary keys and values that, when changed, will trigger an in-place update (redeployment). Useful with `timestamp()` | `map(string)` | `null` | no | +| [volume](#input\_volume) | Configuration block for volumes that containers in your task may use |
map(object({
configure_at_launch = optional(bool)
docker_volume_configuration = optional(object({
autoprovision = optional(bool)
driver = optional(string)
driver_opts = optional(map(string))
labels = optional(map(string))
scope = optional(string)
}))
efs_volume_configuration = optional(object({
authorization_config = optional(object({
access_point_id = optional(string)
iam = optional(string)
}))
file_system_id = string
root_directory = optional(string)
transit_encryption = optional(string)
transit_encryption_port = optional(number)
}))
fsx_windows_file_server_volume_configuration = optional(object({
authorization_config = optional(object({
credentials_parameter = string
domain = string
}))
file_system_id = string
root_directory = string
}))
host_path = optional(string)
name = optional(string)
}))
| `null` | no | +| [volume\_configuration](#input\_volume\_configuration) | Configuration for a volume specified in the task definition as a volume that is configured at launch time |
object({
name = string
managed_ebs_volume = object({
encrypted = optional(bool)
file_system_type = optional(string)
iops = optional(number)
kms_key_id = optional(string)
size_in_gb = optional(number)
snapshot_id = optional(string)
tag_specifications = optional(list(object({
propagate_tags = optional(string, "TASK_DEFINITION")
resource_type = string
tags = optional(map(string))
})))
throughput = optional(number)
volume_initialization_rate = optional(number)
volume_type = optional(string)
})
})
| `null` | no | +| [vpc\_id](#input\_vpc\_id) | The VPC ID where to deploy the task or service. If not provided, the VPC ID is derived from the subnets provided | `string` | `null` | no | +| [vpc\_lattice\_configurations](#input\_vpc\_lattice\_configurations) | The VPC Lattice configuration for your service that allows Lattice to connect, secure, and monitor your service across multiple accounts and VPCs |
object({
role_arn = string
target_group_arn = string
port_name = string
})
| `null` | no | +| [wait\_for\_steady\_state](#input\_wait\_for\_steady\_state) | If true, Terraform will wait for the service to reach a steady state before continuing. Default is `false` | `bool` | `null` | no | +| [wait\_until\_stable](#input\_wait\_until\_stable) | Whether terraform should wait until the task set has reached `STEADY_STATE` | `bool` | `null` | no | +| [wait\_until\_stable\_timeout](#input\_wait\_until\_stable\_timeout) | Wait timeout for task set to reach `STEADY_STATE`. Valid time units include `ns`, `us` (or µs), `ms`, `s`, `m`, and `h`. Default `10m` | `string` | `null` | no | | [workspace](#input\_workspace) | ID element. The Terraform workspace, to help ensure generated IDs are unique across workspaces | `string` | `null` | no | ## Outputs diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 0fd21b71..9f98e545 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -111,4 +111,14 @@ module "ecs_service" { tasks_iam_role_statements = var.tasks_iam_role_statements tasks_iam_role_tags = var.tasks_iam_role_tags tasks_iam_role_use_name_prefix = var.tasks_iam_role_use_name_prefix + timeouts = var.timeouts + track_latest = var.track_latest + triggers = var.triggers + volume = var.volume + volume_configuration = var.volume_configuration + vpc_id = var.vpc_id + vpc_lattice_configurations = var.vpc_lattice_configurations + wait_for_steady_state = var.wait_for_steady_state + wait_until_stable = var.wait_until_stable + wait_until_stable_timeout = var.wait_until_stable_timeout } diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 2007b9aa..78fc6bbc 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -1118,3 +1118,118 @@ variable "tasks_iam_role_use_name_prefix" { type = bool default = true } + +variable "timeouts" { + description = "Create, update, and delete timeout configurations for the service" + type = object({ + create = optional(string) + delete = optional(string) + update = optional(string) + }) + default = null +} + +variable "track_latest" { + description = "Whether should track latest `ACTIVE` task definition on AWS or the one created with the resource stored in state. Useful in the event the task definition is modified outside of this resource" + type = bool + default = true +} + +variable "triggers" { + description = "Map of arbitrary keys and values that, when changed, will trigger an in-place update (redeployment). Useful with `timestamp()`" + type = map(string) + default = null +} + +variable "volume" { + description = "Configuration block for volumes that containers in your task may use" + type = map(object({ + configure_at_launch = optional(bool) + docker_volume_configuration = optional(object({ + autoprovision = optional(bool) + driver = optional(string) + driver_opts = optional(map(string)) + labels = optional(map(string)) + scope = optional(string) + })) + efs_volume_configuration = optional(object({ + authorization_config = optional(object({ + access_point_id = optional(string) + iam = optional(string) + })) + file_system_id = string + root_directory = optional(string) + transit_encryption = optional(string) + transit_encryption_port = optional(number) + })) + fsx_windows_file_server_volume_configuration = optional(object({ + authorization_config = optional(object({ + credentials_parameter = string + domain = string + })) + file_system_id = string + root_directory = string + })) + host_path = optional(string) + name = optional(string) + })) + default = null +} + +variable "volume_configuration" { + description = "Configuration for a volume specified in the task definition as a volume that is configured at launch time" + type = object({ + name = string + managed_ebs_volume = object({ + encrypted = optional(bool) + file_system_type = optional(string) + iops = optional(number) + kms_key_id = optional(string) + size_in_gb = optional(number) + snapshot_id = optional(string) + tag_specifications = optional(list(object({ + propagate_tags = optional(string, "TASK_DEFINITION") + resource_type = string + tags = optional(map(string)) + }))) + throughput = optional(number) + volume_initialization_rate = optional(number) + volume_type = optional(string) + }) + }) + default = null +} + +variable "vpc_id" { + description = "The VPC ID where to deploy the task or service. If not provided, the VPC ID is derived from the subnets provided" + type = string + default = null +} + +variable "vpc_lattice_configurations" { + description = "The VPC Lattice configuration for your service that allows Lattice to connect, secure, and monitor your service across multiple accounts and VPCs" + type = object({ + role_arn = string + target_group_arn = string + port_name = string + }) + default = null +} + +variable "wait_for_steady_state" { + description = "If true, Terraform will wait for the service to reach a steady state before continuing. Default is `false`" + type = bool + default = null +} + +variable "wait_until_stable" { + description = "Whether terraform should wait until the task set has reached `STEADY_STATE`" + type = bool + default = null +} + +variable "wait_until_stable_timeout" { + description = "Wait timeout for task set to reach `STEADY_STATE`. Valid time units include `ns`, `us` (or µs), `ms`, `s`, `m`, and `h`. Default `10m`" + type = string + default = null +} From 291c66def43dbc4a2aba944fc06830cd346e6e69 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:45:19 +0100 Subject: [PATCH 109/118] feat(ecs-service): add outputs --- infrastructure/modules/ecs-service/README.md | 28 +++- infrastructure/modules/ecs-service/outputs.tf | 125 +++++++++++++++++- 2 files changed, 151 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 7a8e2a20..d8895c6e 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -178,7 +178,33 @@ No resources. ## Outputs -No outputs. +| Name | Description | +| ---- | ----------- | +| [autoscaling\_policies](#output\_autoscaling\_policies) | Map of autoscaling policies and their attributes | +| [autoscaling\_scheduled\_actions](#output\_autoscaling\_scheduled\_actions) | Map of autoscaling scheduled actions and their attributes | +| [container\_definitions](#output\_container\_definitions) | Container definitions | +| [ecs\_service\_id](#output\_ecs\_service\_id) | ARN that identifies the service | +| [ecs\_service\_name](#output\_ecs\_service\_name) | Name of the service | +| [iam\_role\_arn](#output\_iam\_role\_arn) | Service IAM role ARN | +| [iam\_role\_name](#output\_iam\_role\_name) | Service IAM role name | +| [iam\_role\_unique\_id](#output\_iam\_role\_unique\_id) | Stable and unique string identifying the service IAM role | +| [infrastructure\_iam\_role\_arn](#output\_infrastructure\_iam\_role\_arn) | Infrastructure IAM role ARN | +| [infrastructure\_iam\_role\_name](#output\_infrastructure\_iam\_role\_name) | Infrastructure IAM role name | +| [security\_group\_arn](#output\_security\_group\_arn) | ARN of the security group | +| [security\_group\_id](#output\_security\_group\_id) | ID of the security group | +| [task\_definition\_arn](#output\_task\_definition\_arn) | Full ARN of the Task Definition (including both `family` and `revision`) | +| [task\_definition\_family](#output\_task\_definition\_family) | The unique name of the task definition | +| [task\_definition\_revision](#output\_task\_definition\_revision) | Revision of the task in a particular family | +| [task\_exec\_iam\_role\_arn](#output\_task\_exec\_iam\_role\_arn) | Task execution IAM role ARN | +| [task\_exec\_iam\_role\_name](#output\_task\_exec\_iam\_role\_name) | Task execution IAM role name | +| [task\_exec\_iam\_role\_unique\_id](#output\_task\_exec\_iam\_role\_unique\_id) | Stable and unique string identifying the task execution IAM role | +| [task\_set\_arn](#output\_task\_set\_arn) | The ARN that identifies the task set | +| [task\_set\_id](#output\_task\_set\_id) | The ID of the task set | +| [task\_set\_stability\_status](#output\_task\_set\_stability\_status) | The stability status. This indicates whether the task set has reached a steady state | +| [task\_set\_status](#output\_task\_set\_status) | The status of the task set | +| [tasks\_iam\_role\_arn](#output\_tasks\_iam\_role\_arn) | Tasks IAM role ARN | +| [tasks\_iam\_role\_name](#output\_tasks\_iam\_role\_name) | Tasks IAM role name | +| [tasks\_iam\_role\_unique\_id](#output\_tasks\_iam\_role\_unique\_id) | Stable and unique string identifying the tasks IAM role | diff --git a/infrastructure/modules/ecs-service/outputs.tf b/infrastructure/modules/ecs-service/outputs.tf index c9fb5240..45595c45 100644 --- a/infrastructure/modules/ecs-service/outputs.tf +++ b/infrastructure/modules/ecs-service/outputs.tf @@ -1 +1,124 @@ -# DAVEH +output "ecs_service_id" { + description = "ARN that identifies the service" + value = module.ecs_service.id +} + +output "ecs_service_name" { + description = "Name of the service" + value = module.ecs_service.name +} + +output "autoscaling_policies" { + description = "Map of autoscaling policies and their attributes" + value = module.ecs_service.autoscaling_policies +} + +output "autoscaling_scheduled_actions" { + description = "Map of autoscaling scheduled actions and their attributes" + value = module.ecs_service.autoscaling_scheduled_actions +} + +output "container_definitions" { + description = "Container definitions" + value = module.ecs_service.container_definitions +} + +output "iam_role_arn" { + description = "Service IAM role ARN" + value = module.ecs_service.iam_role_arn +} + +output "iam_role_name" { + description = "Service IAM role name" + value = module.ecs_service.iam_role_name +} + +output "iam_role_unique_id" { + description = "Stable and unique string identifying the service IAM role" + value = module.ecs_service.iam_role_unique_id +} + +output "infrastructure_iam_role_arn" { + description = "Infrastructure IAM role ARN" + value = module.ecs_service.infrastructure_iam_role_arn +} + +output "infrastructure_iam_role_name" { + description = "Infrastructure IAM role name" + value = module.ecs_service.infrastructure_iam_role_name +} + +output "security_group_arn" { + description = "ARN of the security group" + value = module.ecs_service.security_group_arn +} + +output "security_group_id" { + description = "ID of the security group" + value = module.ecs_service.security_group_id +} + +output "task_definition_arn" { + description = "Full ARN of the Task Definition (including both `family` and `revision`)" + value = module.ecs_service.task_definition_arn +} + +output "task_definition_family" { + description = "The unique name of the task definition" + value = module.ecs_service.task_definition_family +} + +output "task_definition_revision" { + description = "Revision of the task in a particular family" + value = module.ecs_service.task_definition_revision +} + +output "task_exec_iam_role_arn" { + description = "Task execution IAM role ARN" + value = module.ecs_service.task_exec_iam_role_arn +} + +output "task_exec_iam_role_name" { + description = "Task execution IAM role name" + value = module.ecs_service.task_exec_iam_role_name +} + +output "task_exec_iam_role_unique_id" { + description = "Stable and unique string identifying the task execution IAM role" + value = module.ecs_service.task_exec_iam_role_unique_id +} + +output "task_set_arn" { + description = "The ARN that identifies the task set" + value = module.ecs_service.task_set_arn +} + +output "task_set_id" { + description = "The ID of the task set" + value = module.ecs_service.task_set_id +} + +output "task_set_stability_status" { + description = "The stability status. This indicates whether the task set has reached a steady state" + value = module.ecs_service.task_set_stability_status +} + +output "task_set_status" { + description = "The status of the task set" + value = module.ecs_service.task_set_status +} + +output "tasks_iam_role_arn" { + description = "Tasks IAM role ARN" + value = module.ecs_service.tasks_iam_role_arn +} + +output "tasks_iam_role_name" { + description = "Tasks IAM role name" + value = module.ecs_service.tasks_iam_role_name +} + +output "tasks_iam_role_unique_id" { + description = "Stable and unique string identifying the tasks IAM role" + value = module.ecs_service.tasks_iam_role_unique_id +} From a0b912f96809c435a097bb57300bdbef9e59160d Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 12:47:46 +0100 Subject: [PATCH 110/118] docs(ecs-service): remove unnecessary comment --- infrastructure/modules/ecs-service/README.md | 2 +- infrastructure/modules/ecs-service/variables.tf | 11 +++++------ 2 files changed, 6 insertions(+), 7 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index d8895c6e..993603fe 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -43,7 +43,7 @@ No resources. | [aws\_region](#input\_aws\_region) | The AWS region | `string` | `"eu-west-2"` | no | | [capacity\_provider\_strategy](#input\_capacity\_provider\_strategy) | Capacity provider strategies to use for the service. Can be one or more |
map(object({
base = optional(number)
capacity_provider = string
weight = optional(number)
}))
| `null` | no | | [cluster\_arn](#input\_cluster\_arn) | ARN of the ECS cluster where the resources will be provisioned | `string` | `""` | no | -| [container\_definitions](#input\_container\_definitions) | A map of valid container definitions . Please note that you should only provide values that are part of the container definition document |
map(object({
create = optional(bool, true)
operating_system_family = optional(string)
tags = optional(map(string)) # Container definition
command = optional(list(string))
cpu = optional(number)
credentialSpecs = optional(list(string))
dependsOn = optional(list(object({
condition = string
containerName = string
})))
disableNetworking = optional(bool)
dnsSearchDomains = optional(list(string))
dnsServers = optional(list(string))
dockerLabels = optional(map(string))
dockerSecurityOptions = optional(list(string))
# DAVEH: following line was comment to preceeding line
enable_execute_command = optional(bool, false) # Set in standalone variable
entrypoint = optional(list(string))
environment = optional(list(object({
name = string
value = string
})))
environmentFiles = optional(list(object({
type = string
value = string
})))
essential = optional(bool)
extraHosts = optional(list(object({
hostname = string
ipAddress = string
})))
firelensConfiguration = optional(object({
options = optional(map(string))
type = optional(string)
}))
healthCheck = optional(object({
command = optional(list(string), [])
interval = optional(number, 30)
retries = optional(number, 3)
startPeriod = optional(number)
timeout = optional(number, 5)
}))
hostname = optional(string)
image = optional(string)
interactive = optional(bool)
links = optional(list(string))
linuxParameters = optional(object({
capabilities = optional(object({
add = optional(list(string))
drop = optional(list(string))
}))
devices = optional(list(object({
containerPath = optional(string)
hostPath = optional(string)
permissions = optional(list(string))
})))
initProcessEnabled = optional(bool)
maxSwap = optional(number)
sharedMemorySize = optional(number)
swappiness = optional(number)
tmpfs = optional(list(object({
containerPath = string
mountOptions = optional(list(string))
size = number
})))
}))
logConfiguration = optional(object({
logDriver = optional(string)
options = optional(map(string))
secretOptions = optional(list(object({
name = string
valueFrom = string
})))
}))
memory = optional(number)
memoryReservation = optional(number)
mountPoints = optional(list(object({
containerPath = optional(string)
readOnly = optional(bool)
sourceVolume = optional(string)
})))
name = optional(string)
portMappings = optional(list(object({
appProtocol = optional(string)
containerPort = optional(number)
containerPortRange = optional(string)
hostPort = optional(number)
name = optional(string)
protocol = optional(string)
})))
privileged = optional(bool)
pseudoTerminal = optional(bool)
readonlyRootFilesystem = optional(bool)
repositoryCredentials = optional(object({
credentialsParameter = optional(string)
}))
resourceRequirements = optional(list(object({
type = string
value = string
})))
restartPolicy = optional(object({
enabled = optional(bool)
ignoredExitCodes = optional(list(number))
restartAttemptPeriod = optional(number)
}))
secrets = optional(list(object({
name = string
valueFrom = string
})))
startTimeout = optional(number, 30)
stopTimeout = optional(number, 120)
systemControls = optional(list(object({
namespace = optional(string)
value = optional(string)
})))
ulimits = optional(list(object({
hardLimit = number
name = string
softLimit = number
})))
user = optional(string)
versionConsistency = optional(string)
volumesFrom = optional(list(object({
readOnly = optional(bool)
sourceContainer = optional(string)
})))
workingDirectory = optional(string)
# Cloudwatch Log Group
service = optional(string)
enable_cloudwatch_logging = optional(bool)
create_cloudwatch_log_group = optional(bool)
cloudwatch_log_group_name = optional(string)
cloudwatch_log_group_use_name_prefix = optional(bool)
cloudwatch_log_group_class = optional(string)
cloudwatch_log_group_retention_in_days = optional(number)
cloudwatch_log_group_kms_key_id = optional(string)
}))
| `{}` | no | +| [container\_definitions](#input\_container\_definitions) | A map of valid container definitions . Please note that you should only provide values that are part of the container definition document |
map(object({
create = optional(bool, true)
operating_system_family = optional(string)
tags = optional(map(string)) # Container definition
command = optional(list(string))
cpu = optional(number)
credentialSpecs = optional(list(string))
dependsOn = optional(list(object({
condition = string
containerName = string
})))
disableNetworking = optional(bool)
dnsSearchDomains = optional(list(string))
dnsServers = optional(list(string))
dockerLabels = optional(map(string))
dockerSecurityOptions = optional(list(string))
enable_execute_command = optional(bool, false) # Set in standalone variable
entrypoint = optional(list(string))
environment = optional(list(object({
name = string
value = string
})))
environmentFiles = optional(list(object({
type = string
value = string
})))
essential = optional(bool)
extraHosts = optional(list(object({
hostname = string
ipAddress = string
})))
firelensConfiguration = optional(object({
options = optional(map(string))
type = optional(string)
}))
healthCheck = optional(object({
command = optional(list(string), [])
interval = optional(number, 30)
retries = optional(number, 3)
startPeriod = optional(number)
timeout = optional(number, 5)
}))
hostname = optional(string)
image = optional(string)
interactive = optional(bool)
links = optional(list(string))
linuxParameters = optional(object({
capabilities = optional(object({
add = optional(list(string))
drop = optional(list(string))
}))
devices = optional(list(object({
containerPath = optional(string)
hostPath = optional(string)
permissions = optional(list(string))
})))
initProcessEnabled = optional(bool)
maxSwap = optional(number)
sharedMemorySize = optional(number)
swappiness = optional(number)
tmpfs = optional(list(object({
containerPath = string
mountOptions = optional(list(string))
size = number
})))
}))
logConfiguration = optional(object({
logDriver = optional(string)
options = optional(map(string))
secretOptions = optional(list(object({
name = string
valueFrom = string
})))
}))
memory = optional(number)
memoryReservation = optional(number)
mountPoints = optional(list(object({
containerPath = optional(string)
readOnly = optional(bool)
sourceVolume = optional(string)
})))
name = optional(string)
portMappings = optional(list(object({
appProtocol = optional(string)
containerPort = optional(number)
containerPortRange = optional(string)
hostPort = optional(number)
name = optional(string)
protocol = optional(string)
})))
privileged = optional(bool)
pseudoTerminal = optional(bool)
readonlyRootFilesystem = optional(bool)
repositoryCredentials = optional(object({
credentialsParameter = optional(string)
}))
resourceRequirements = optional(list(object({
type = string
value = string
})))
restartPolicy = optional(object({
enabled = optional(bool)
ignoredExitCodes = optional(list(number))
restartAttemptPeriod = optional(number)
}))
secrets = optional(list(object({
name = string
valueFrom = string
})))
startTimeout = optional(number, 30)
stopTimeout = optional(number, 120)
systemControls = optional(list(object({
namespace = optional(string)
value = optional(string)
})))
ulimits = optional(list(object({
hardLimit = number
name = string
softLimit = number
})))
user = optional(string)
versionConsistency = optional(string)
volumesFrom = optional(list(object({
readOnly = optional(bool)
sourceContainer = optional(string)
})))
workingDirectory = optional(string)
# Cloudwatch Log Group
service = optional(string)
enable_cloudwatch_logging = optional(bool)
create_cloudwatch_log_group = optional(bool)
cloudwatch_log_group_name = optional(string)
cloudwatch_log_group_use_name_prefix = optional(bool)
cloudwatch_log_group_class = optional(string)
cloudwatch_log_group_retention_in_days = optional(number)
cloudwatch_log_group_kms_key_id = optional(string)
}))
| `{}` | 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 | | [cpu](#input\_cpu) | Number of cpu units used by the task. If the `requires_compatibilities` is `FARGATE` this field is required | `number` | `1024` | no | | [create\_iam\_role](#input\_create\_iam\_role) | Determines whether the ECS service IAM role should be created | `bool` | `true` | no | diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 78fc6bbc..bb6f8fa8 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -241,12 +241,11 @@ variable "container_definitions" { condition = string containerName = string }))) - disableNetworking = optional(bool) - dnsSearchDomains = optional(list(string)) - dnsServers = optional(list(string)) - dockerLabels = optional(map(string)) - dockerSecurityOptions = optional(list(string)) - # DAVEH: following line was comment to preceeding line + disableNetworking = optional(bool) + dnsSearchDomains = optional(list(string)) + dnsServers = optional(list(string)) + dockerLabels = optional(map(string)) + dockerSecurityOptions = optional(list(string)) enable_execute_command = optional(bool, false) # Set in standalone variable entrypoint = optional(list(string)) environment = optional(list(object({ From 346451e642865c138081a5391c587c5a2171279d Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 13:41:41 +0100 Subject: [PATCH 111/118] docs(ecs-service): write intro --- infrastructure/modules/ecs-service/README.md | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 993603fe..3e0e38cd 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -1,4 +1,8 @@ -# DAVEH +# ECS-Service + +NHS Screening wrapper around the community +[`terraform-aws-modules/ecs/aws//modules/service`](https://registry.terraform.io/modules/terraform-aws-modules/ecs/aws/latest/submodules/service) +submodule that consumes the shared `context.tf` for naming and tagging. From 521bd3149546d6b2960bc86998317f633867e3e2 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Mon, 22 Jun 2026 13:45:08 +0100 Subject: [PATCH 112/118] docs(ecs-service): write examples --- infrastructure/modules/ecs-service/README.md | 106 +++++++++++++++++++ 1 file changed, 106 insertions(+) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 3e0e38cd..b24505c1 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -4,6 +4,112 @@ NHS Screening wrapper around the community [`terraform-aws-modules/ecs/aws//modules/service`](https://registry.terraform.io/modules/terraform-aws-modules/ecs/aws/latest/submodules/service) submodule that consumes the shared `context.tf` for naming and tagging. +## Usage + +### Minimal Fargate service + +```hcl +module "api_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + + service = "bcss" + project = "api" + environment = "development" + name = "web-api" + cluster_arn = module.ecs_cluster.arn + subnet_ids = module.vpc.private_subnets + + container_definitions = { + app = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/my-app:latest" + cpu = 512 + memory = 1024 + essential = true + } + } +} +``` + +### Fargate service with load balancer + +```hcl +module "web_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + + service = "bcss" + project = "web" + environment = "prod" + name = "frontend" + cluster_arn = module.ecs_cluster.arn + subnet_ids = module.vpc.private_subnets + assign_public_ip = false + + container_definitions = { + web = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/web-app:v1.2.3" + cpu = 512 + memory = 1024 + essential = true + port_mappings = [{ + container_port = 8080 + host_port = 8080 + }] + } + } + + load_balancer = { + web = { + container_name = "web" + container_port = 8080 + target_group_arn = module.alb.target_group_arn + } + } + + health_check_grace_period_seconds = 60 +} +``` + +### Fargate service with autoscaling + +```hcl +module "worker_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + + service = "bcss" + project = "jobs" + environment = "prod" + name = "background-worker" + cluster_arn = module.ecs_cluster.arn + subnet_ids = module.vpc.private_subnets + + container_definitions = { + worker = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/worker:latest" + cpu = 256 + memory = 512 + essential = true + } + } + + desired_count = 2 + enable_autoscaling = true + autoscaling_min_capacity = 2 + autoscaling_max_capacity = 10 + + autoscaling_policies = { + cpu = { + policy_type = "TargetTrackingScaling" + target_tracking_scaling_policy_configuration = { + target_value = 70.0 + predefined_metric_specification = { + predefined_metric_type = "ECSServiceAverageCPUUtilization" + } + } + } + } +} +``` + From 5b0cef4172b8f8d8af3bd60f655a3ece3fe426e8 Mon Sep 17 00:00:00 2001 From: Oliver Slater Date: Mon, 22 Jun 2026 16:48:08 +0100 Subject: [PATCH 113/118] feat(ecs-service): enhance README and add locals for service name management --- infrastructure/modules/ecs-service/README.md | 196 ++++++++++++++++-- infrastructure/modules/ecs-service/locals.tf | 6 + infrastructure/modules/ecs-service/main.tf | 20 +- .../modules/ecs-service/variables.tf | 13 ++ 4 files changed, 214 insertions(+), 21 deletions(-) create mode 100644 infrastructure/modules/ecs-service/locals.tf diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index b24505c1..98a686e5 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -1,8 +1,20 @@ -# ECS-Service +# ECS Service NHS Screening wrapper around the community [`terraform-aws-modules/ecs/aws//modules/service`](https://registry.terraform.io/modules/terraform-aws-modules/ecs/aws/latest/submodules/service) -submodule that consumes the shared `context.tf` for naming and tagging. +submodule that enforces the platform's baseline controls and consumes +the shared `context.tf` for naming and tagging. + +## What this module enforces + +|Control|How it is enforced| +|---|---| +|No public IP|`assign_public_ip` defaults to `false`; tasks run on private subnets| +|ECS-managed tags|`enable_ecs_managed_tags` defaults to `true`; AWS propagates resource tags to tasks| +|Tag propagation|`propagate_tags` defaults to `TASK_DEFINITION` so tasks inherit service tags| +|Creation gate|`create = module.this.enabled`; no resources are created when the module is disabled| +|Consistent naming|Service name is sourced from `local.service_name` (context-derived or `var.service_name` override)| +|Consistent tagging|All resources tagged via `module.this.tags`| ## Usage @@ -10,7 +22,7 @@ submodule that consumes the shared `context.tf` for naming and tagging. ```hcl module "api_service" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" service = "bcss" project = "api" @@ -34,15 +46,16 @@ module "api_service" { ```hcl module "web_service" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" - service = "bcss" - project = "web" - environment = "prod" - name = "frontend" - cluster_arn = module.ecs_cluster.arn - subnet_ids = module.vpc.private_subnets - assign_public_ip = false + service = "bcss" + project = "web" + environment = "prod" + name = "frontend" + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets container_definitions = { web = { @@ -73,14 +86,16 @@ module "web_service" { ```hcl module "worker_service" { - source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=main" + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" - service = "bcss" - project = "jobs" - environment = "prod" - name = "background-worker" - cluster_arn = module.ecs_cluster.arn - subnet_ids = module.vpc.private_subnets + service = "bcss" + project = "jobs" + environment = "prod" + name = "background-worker" + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets container_definitions = { worker = { @@ -110,6 +125,148 @@ module "worker_service" { } ``` +### Service reading secrets from Secrets Manager + +```hcl +module "api_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" + + service = "bcss" + project = "api" + environment = "prod" + name = "web-api" + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets + + # Grant the task execution role permission to read these secrets. + # The values are injected into the container at runtime via the + # secrets block in the container definition below. + task_exec_secret_arns = [ + module.db_credentials.secret_arn, + module.api_key.secret_arn, + ] + + container_definitions = { + app = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/my-app:latest" + cpu = 512 + memory = 1024 + essential = true + + secrets = [ + { + name = "DB_PASSWORD" + valueFrom = module.db_credentials.secret_arn + }, + { + name = "API_KEY" + valueFrom = module.api_key.secret_arn + }, + ] + } + } +} +``` + +### Blue/green deployment + +```hcl +module "api_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" + + service = "bcss" + project = "api" + environment = "prod" + name = "web-api" + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets + + deployment_configuration = { + strategy = "BLUE_GREEN" + bake_time_in_minutes = 5 + } + + deployment_circuit_breaker = { + enable = true + rollback = true + } + + container_definitions = { + app = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/my-app:latest" + cpu = 512 + memory = 1024 + essential = true + } + } +} +``` + +### Preserving an existing service name + +Use `service_name` when you wish to explicitly define a service name i.e. when the ECS service was previously created outside Terraform and the name must be kept stable: + +```hcl +module "legacy_service" { + source = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/ecs-service?ref=" + + service = "bcss" + project = "api" + environment = "prod" + name = "web-api" + + service_name = "bcss-legacy-worker" # overrides the context-derived name + + cluster_arn = module.ecs_cluster.arn + vpc_id = module.vpc.vpc_id + subnet_ids = module.vpc.private_subnets + + container_definitions = { + app = { + image = "my-registry.dkr.ecr.eu-west-2.amazonaws.com/my-app:latest" + cpu = 256 + memory = 512 + essential = true + } + } +} +``` + +## Conventions + +- Service name defaults to `module.this.name` (derived from `context.tf`). Supply + `service`, `project`, `environment`, and `name` inputs to control the generated + name. Set `service_name` to override the context-derived name with an explicit + value — useful when an existing ECS service name must be preserved. +- `assign_public_ip` defaults to `false`. Only set it to `true` for public-facing + Fargate services that intentionally require direct internet access; this is rare + in the NHS Screening platform. +- `enable_autoscaling` defaults to `true`. Set it to `false` for batch or + short-lived services that do not require auto-scaling. +- `desired_count` defaults to `1`. Adjust to match your workload requirements. +- The caller must supply the ECS cluster ARN (`cluster_arn`) and the VPC subnet + IDs (`subnet_ids`). These are not managed by this module. + +## What this module does NOT do + +- Create an ECS cluster. Use the `ecs-cluster` module and pass the ARN via + `cluster_arn`. +- Create VPCs, subnets, or security groups. The caller is responsible for + networking; pass existing security group IDs via `security_group_ids` or let + the module create one via `create_security_group = true`. +- Create load balancers or target groups. Configure these separately and pass + the target group ARN(s) via `load_balancer`. +- Build or push container images. Use the `ecr` module for the registry. +- Manage KMS encryption at the ECS service level. Task-level secrets encryption + is handled via the task execution IAM role and the `secrets-manager` or + `parameter_store` modules. +- Create CloudWatch log groups. Define these in your container definitions or + provision them separately. + @@ -128,7 +285,7 @@ No providers. | Name | Source | Version | | ---- | ------ | ------- | -| [ecs\_service](#module\_ecs\_service) | terraform-aws-modules/ecs/aws//modules/service | ~> 7.5.0 | +| [ecs\_service](#module\_ecs\_service) | terraform-aws-modules/ecs/aws//modules/service | 7.5.0 | | [this](#module\_this) | ../tags | n/a | ## Resources @@ -238,6 +395,7 @@ No resources. | [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 | | [service\_connect\_configuration](#input\_service\_connect\_configuration) | The ECS Service Connect configuration for this service to discover and connect to services, and be discovered by, and connected from, other services within a namespace |
object({
enabled = optional(bool, true)
access_log_configuration = optional(object({
format = string
include_query_parameters = optional(string)
}))
log_configuration = optional(object({
log_driver = string
options = optional(map(string))
secret_option = optional(list(object({
name = string
value_from = string
})))
}))
namespace = optional(string)
service = optional(list(object({
client_alias = optional(object({
dns_name = optional(string)
port = number
test_traffic_rules = optional(list(object({
header = optional(object({
name = string
value = object({
exact = string
})
}))
})))
}))
discovery_name = optional(string)
ingress_port_override = optional(number)
port_name = string
timeout = optional(object({
idle_timeout_seconds = optional(number)
per_request_timeout_seconds = optional(number)
}))
tls = optional(object({
issuer_cert_authority = object({
aws_pca_authority_arn = string
})
kms_key = optional(string)
role_arn = optional(string)
}))
})))
})
| `null` | no | +| [service\_name](#input\_service\_name) | Name of the service | `string` | `null` | no | | [service\_registries](#input\_service\_registries) | Service discovery registries for the service |
object({
container_name = optional(string)
container_port = optional(number)
port = optional(number)
registry_arn = string
})
| `null` | no | | [service\_tags](#input\_service\_tags) | A map of additional tags to add to the service | `map(string)` | `{}` | no | | [sigint\_rollback](#input\_sigint\_rollback) | Whether to enable graceful termination of deployments using SIGINT signals. Only applicable when using ECS deployment controller and requires wait\_for\_steady\_state = true. Default is false | `bool` | `null` | no | diff --git a/infrastructure/modules/ecs-service/locals.tf b/infrastructure/modules/ecs-service/locals.tf new file mode 100644 index 00000000..4ff52fa3 --- /dev/null +++ b/infrastructure/modules/ecs-service/locals.tf @@ -0,0 +1,6 @@ +locals { + # Service name is derived from context. The community module receives + # module.this.name directly so that context-driven label ordering is + # preserved without an intermediate local in the common case. + service_name = var.service_name != null ? var.service_name : module.this.name +} diff --git a/infrastructure/modules/ecs-service/main.tf b/infrastructure/modules/ecs-service/main.tf index 9f98e545..054ea646 100644 --- a/infrastructure/modules/ecs-service/main.tf +++ b/infrastructure/modules/ecs-service/main.tf @@ -1,9 +1,25 @@ +################################################################ +# ECS Service +# +# Thin NHS wrapper around the community ECS service submodule +# (terraform-aws-modules/ecs/aws//modules/service) that +# enforces the screening platform's baseline controls: +# +# * No public IP: assign_public_ip defaults to false +# * ECS-managed tags: enable_ecs_managed_tags defaults to true +# * Tag propagation: propagate_tags defaults to TASK_DEFINITION +# * Creation gated by module.this.enabled +# +# Naming: context.tf via module.this by default; caller may override +# with var.service_name. Tagging is always via module.this.tags. +################################################################ + module "ecs_service" { source = "terraform-aws-modules/ecs/aws//modules/service" - version = "~> 7.5.0" + version = "7.5.0" create = module.this.enabled - name = module.this.name + name = local.service_name tags = module.this.tags alarms = var.alarms diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index bb6f8fa8..bdb6ed0c 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -1,3 +1,10 @@ +################################################################ +# ECS Service-specific inputs. +# +# Naming, tagging and the master `enabled` switch come from +# context.tf via `module.this`. +################################################################ + variable "alarms" { description = "Information about the CloudWatch alarms" type = object({ @@ -884,6 +891,12 @@ variable "service_connect_configuration" { default = null } +variable "service_name" { + description = "Name of the service" + type = string + default = null +} + variable "service_registries" { description = "Service discovery registries for the service" type = object({ From 067d350bc0e86c62193a9ed73460a973ba43d3ec Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Tue, 30 Jun 2026 11:44:28 +0100 Subject: [PATCH 114/118] fix(ecs-service): update default enable_fault_injection value (code review) --- README.md | 1 + infrastructure/modules/ecs-service/README.md | 2 +- infrastructure/modules/ecs-service/variables.tf | 2 +- 3 files changed, 3 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 82c12e7f..1f4d50e6 100644 --- a/README.md +++ b/README.md @@ -316,6 +316,7 @@ Rules: | `ec2-instance` | — | — | | `ecr` | — | ECR repository with security controls | | `ecs-cluster` | terraform-aws-modules/ecs/aws//modules/cluster | ECS Fargate cluster | +| `ecs-service` | terraform-aws-modules/ecs/aws//modules/service | ECS service and task definition | | `elasticache` | — | ElastiCache cluster (Redis/Memcached) | | `github-config` | — | GitHub OIDC provider and runner configuration | | `guardduty` | — | GuardDuty threat detection | diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 98a686e5..52395002 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -334,7 +334,7 @@ No resources. | [enable\_autoscaling](#input\_enable\_autoscaling) | Determines whether to enable autoscaling for the service | `bool` | `true` | no | | [enable\_ecs\_managed\_tags](#input\_enable\_ecs\_managed\_tags) | Specifies whether to enable Amazon ECS managed tags for the tasks within the service | `bool` | `true` | no | | [enable\_execute\_command](#input\_enable\_execute\_command) | Specifies whether to enable Amazon ECS Exec for the tasks within the service | `bool` | `false` | no | -| [enable\_fault\_injection](#input\_enable\_fault\_injection) | Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false` | `bool` | `null` | no | +| [enable\_fault\_injection](#input\_enable\_fault\_injection) | Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false` | `bool` | `false` | no | | [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources | `bool` | `null` | no | | [environment](#input\_environment) | ID element. Usually used to indicate role, e.g. 'prd', 'dev', 'test', 'preprod', 'prod', 'uat' | `string` | `null` | no | | [ephemeral\_storage](#input\_ephemeral\_storage) | The amount of ephemeral storage to allocate for the task. This parameter is used to expand the total amount of ephemeral storage available, beyond the default amount, for tasks hosted on AWS Fargate |
object({
size_in_gib = number
})
| `null` | no | diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index bdb6ed0c..fa5ce03c 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -510,7 +510,7 @@ variable "enable_execute_command" { variable "enable_fault_injection" { description = "Enables fault injection and allows for fault injection requests to be accepted from the task's containers. Default is `false`" type = bool - default = null + default = false } variable "ephemeral_storage" { From 5d911ade84a2f6e3a14fd469320fbab86927ac41 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Tue, 30 Jun 2026 11:48:49 +0100 Subject: [PATCH 115/118] fix(ecs-service): set default propagate_tags value and add validation (code review) --- infrastructure/modules/ecs-service/README.md | 2 +- infrastructure/modules/ecs-service/variables.tf | 8 +++++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 52395002..774d3c82 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -376,7 +376,7 @@ No resources. | [placement\_constraints](#input\_placement\_constraints) | Configuration block for rules that are taken into consideration during task placement (up to max of 10). This is set at the service, see `task_definition_placement_constraints` for setting at the task definition |
map(object({
expression = optional(string)
type = string
}))
| `null` | no | | [platform\_version](#input\_platform\_version) | Platform version on which to run your service. Only applicable for `launch_type` set to `FARGATE`. Defaults to `LATEST` | `string` | `null` | 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 | -| [propagate\_tags](#input\_propagate\_tags) | Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION` | `string` | `null` | no | +| [propagate\_tags](#input\_propagate\_tags) | Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION` | `string` | `"TASK_DEFINITION"` | no | | [proxy\_configuration](#input\_proxy\_configuration) | Configuration block for the App Mesh proxy |
object({
container_name = string
properties = optional(map(string))
type = optional(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 | diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index fa5ce03c..20b6619a 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -735,7 +735,13 @@ variable "platform_version" { variable "propagate_tags" { description = "Specifies whether to propagate the tags from the task definition or the service to the tasks. The valid values are `SERVICE` and `TASK_DEFINITION`" type = string - default = null + default = "TASK_DEFINITION" + nullable = false + + validation { + condition = contains(["SERVICE", "TASK_DEFINITION"], var.propagate_tags) + error_message = "propagate_tags must be one of \"SERVICE\" or \"TASK_DEFINITION\"." + } } variable "proxy_configuration" { From 27e63d2b335c5177d65c4219e1bcf3add14db30d Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Tue, 30 Jun 2026 13:00:50 +0100 Subject: [PATCH 116/118] fix(ecs-cluster): add cluster_arn validation --- infrastructure/modules/ecs-service/variables.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf index 20b6619a..88905d0a 100644 --- a/infrastructure/modules/ecs-service/variables.tf +++ b/infrastructure/modules/ecs-service/variables.tf @@ -233,6 +233,11 @@ variable "cluster_arn" { description = "ARN of the ECS cluster where the resources will be provisioned" type = string default = "" + + validation { + condition = var.cluster_arn != "" || var.create_service == false || module.this.enabled == false + error_message = "cluster_arn must be provided if we create a service" + } } variable "container_definitions" { From d2844020364909ec9529b0d233617c16fbaa4549 Mon Sep 17 00:00:00 2001 From: Dave Hinton Date: Wed, 1 Jul 2026 08:42:31 +0100 Subject: [PATCH 117/118] docs(ecs-service): add corrections to readme --- infrastructure/modules/ecs-service/README.md | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/infrastructure/modules/ecs-service/README.md b/infrastructure/modules/ecs-service/README.md index 774d3c82..d07c78e4 100644 --- a/infrastructure/modules/ecs-service/README.md +++ b/infrastructure/modules/ecs-service/README.md @@ -255,7 +255,7 @@ module "legacy_service" { - Create an ECS cluster. Use the `ecs-cluster` module and pass the ARN via `cluster_arn`. -- Create VPCs, subnets, or security groups. The caller is responsible for +- Create VPCs or subnets. The caller is responsible for networking; pass existing security group IDs via `security_group_ids` or let the module create one via `create_security_group = true`. - Create load balancers or target groups. Configure these separately and pass @@ -264,8 +264,6 @@ module "legacy_service" { - Manage KMS encryption at the ECS service level. Task-level secrets encryption is handled via the task execution IAM role and the `secrets-manager` or `parameter_store` modules. -- Create CloudWatch log groups. Define these in your container definitions or - provision them separately. From 35d57d87e092605e905bcc046ecf153bd9c18ada Mon Sep 17 00:00:00 2001 From: Pira-nhs Date: Thu, 9 Jul 2026 11:59:20 +0100 Subject: [PATCH 118/118] Merge branch 'feature/BCSS-23563-add-ecs-service-module' of github.com:NHSDigital/screening-terraform-modules-aws into feature/BCSS-23563-add-ecs-service-module