diff --git a/.github/dependabot.yaml b/.github/dependabot.yaml index ed8d5c8a..69eecd92 100644 --- a/.github/dependabot.yaml +++ b/.github/dependabot.yaml @@ -36,14 +36,20 @@ 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" - "infrastructure/modules/aws-scheduler" + - "infrastructure/modules/cloudwatch-log-metric-filter" + - "infrastructure/modules/cloudwatch-logs" + - "infrastructure/modules/cloudwatch-metric-alarm" - "infrastructure/modules/cognito" - "infrastructure/modules/cw-firehose-splunk" + - "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" @@ -61,6 +67,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" @@ -68,6 +75,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 bc97b9f3..1f4d50e6 100644 --- a/README.md +++ b/README.md @@ -298,18 +298,25 @@ Rules: ## Available modules + | Module | Wraps | Description | | --- | --- | --- | | `acm` | terraform-aws-modules/acm/aws | AWS Certificate Manager (ACM) certificate management | +| `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 | | `aws-scheduler` | — | EventBridge Scheduler configuration | +| `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 | +| `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 | @@ -323,6 +330,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 | @@ -334,6 +342,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) | @@ -341,6 +350,7 @@ Rules: | `waf` | — | WAF web ACL with rules | + ## Pre-commit hooks 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", + ] +} diff --git a/infrastructure/modules/alb/README.md b/infrastructure/modules/alb/README.md new file mode 100644 index 00000000..e1a97867 --- /dev/null +++ b/infrastructure/modules/alb/README.md @@ -0,0 +1,282 @@ +# 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. + +## What this module enforces + +| Setting | Value | Reason | +|---|---|---| +| `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" + } + } + + # HTTP → HTTPS redirect on port 80 is added automatically by this module. + listeners = { + 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" + } + } + } +} +``` + +## 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. +* `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. + +## 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 | +|---|---|---| +| `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](#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. | + + + diff --git a/infrastructure/modules/alb/context.tf b/infrastructure/modules/alb/context.tf new file mode 100644 index 00000000..f813fc8e --- /dev/null +++ b/infrastructure/modules/alb/context.tf @@ -0,0 +1,374 @@ +# 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 +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/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 new file mode 100644 index 00000000..9931e70c --- /dev/null +++ b/infrastructure/modules/alb/main.tf @@ -0,0 +1,69 @@ +################################################################ +# Application / Network Load Balancer +# +# Thin NHS wrapper around the community ALB module that enforces +# the screening platform's baseline controls: +# +# * 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): +# - 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 — 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 = var.enable_deletion_protection + 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 — merged with the automatic HTTP→HTTPS redirect (ALB only). + # Target groups are passed through as-is. + # ---------------------------------------------------------------- + listeners = local.effective_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..a823e7e2 --- /dev/null +++ b/infrastructure/modules/alb/variables.tf @@ -0,0 +1,122 @@ +################################################################ +# 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." +} + +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." +} diff --git a/infrastructure/modules/alb/versions.tf b/infrastructure/modules/alb/versions.tf new file mode 100644 index 00000000..cb30fe5c --- /dev/null +++ b/infrastructure/modules/alb/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-log-metric-filter/.terraform.lock.hcl b/infrastructure/modules/cloudwatch-log-metric-filter/.terraform.lock.hcl new file mode 100644 index 00000000..82c67711 --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/.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 = ">= 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-log-metric-filter/README.md b/infrastructure/modules/cloudwatch-log-metric-filter/README.md new file mode 100644 index 00000000..33685df9 --- /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) | 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 | +| [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..ba67029c --- /dev/null +++ b/infrastructure/modules/cloudwatch-log-metric-filter/main.tf @@ -0,0 +1,25 @@ +################################################################ +# 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 +} 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..27d5ca11 --- /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 = "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" { + 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..82c67711 --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/.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 = ">= 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-logs/README.md b/infrastructure/modules/cloudwatch-logs/README.md new file mode 100644 index 00000000..4a23b182 --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/README.md @@ -0,0 +1,157 @@ +# 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 | 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 + +### Log group only + +```hcl +module "app_logs" { + source = "../../modules/cloudwatch-logs" + + context = module.this.context + stack = "app" + name = "ecs" + + create = "LOG_GROUP_ONLY" + 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_AND_LOG_STREAM" + 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_ONLY" +} + +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` 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. + + + + +## 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](#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 | +| [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) | 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 | +| [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..38c2a9df --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/locals.tf @@ -0,0 +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 new file mode 100644 index 00000000..6904ee5c --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/main.tf @@ -0,0 +1,36 @@ +################################################################ +# 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 && local.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 && local.create_log_stream + + name = local.log_stream_name + log_group_name = module.log_group.cloudwatch_log_group_name +} diff --git a/infrastructure/modules/cloudwatch-logs/outputs.tf b/infrastructure/modules/cloudwatch-logs/outputs.tf new file mode 100644 index 00000000..dc6dbbc6 --- /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 = 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 = 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 = 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 = local.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..4b2725c9 --- /dev/null +++ b/infrastructure/modules/cloudwatch-logs/variables.tf @@ -0,0 +1,34 @@ +################################################################ +# CloudWatch Logs submodule inputs. +# +# Naming, tagging and the master `enabled` switch come from +# context.tf via `module.this`. +################################################################ + +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." + + 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" { + 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 = "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-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..82c67711 --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/.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 = ">= 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-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..3bb2285d --- /dev/null +++ b/infrastructure/modules/cloudwatch-metric-alarm/variables.tf @@ -0,0 +1,87 @@ +################################################################ +# 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." + + 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" { + 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." + + 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" { + 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/infrastructure/modules/cognito/.terraform.lock.hcl b/infrastructure/modules/cognito/.terraform.lock.hcl index ef2e44ed..74487bfd 100644 --- a/infrastructure/modules/cognito/.terraform.lock.hcl +++ b/infrastructure/modules/cognito/.terraform.lock.hcl @@ -3,7 +3,7 @@ provider "registry.terraform.io/hashicorp/aws" { version = "6.50.0" - constraints = ">= 6.47.0" + constraints = ">= 6.0.0, >= 6.14.0, >= 6.42.0" hashes = [ "h1:8y10QFtGLHl3pF/R1/hO7VCPHTexm1whc0BfuG4uruw=", "h1:D8uNiOpl3UkAX4zI5T47ALMiRFXTa1XfdQC+TBu3RmE=", @@ -29,27 +29,28 @@ provider "registry.terraform.io/hashicorp/aws" { ] } -provider "registry.terraform.io/hashicorp/random" { - version = "3.9.0" - constraints = ">= 3.9.0" +provider "registry.terraform.io/hashicorp/awscc" { + version = "1.89.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", + "h1:DyIiErDhoQCJi54zEUf7E4GRm5FzAEHJFW9GTu+SNkc=", + "h1:F672dO9zM66TDfIHttDmaENXwd9ZyKi2SiFNA7aOjfo=", + "h1:QZYfne5HgtW+iL0SCZXNIspeDQUIBty+5clq754wXWg=", + "h1:VoA2As0xZ05sC1lFPKActbHEAB4+3WHj/9WuxWMX0ps=", + "h1:ov0Eyeineeq629FvErPttIVKgBV1d0QZJT2zEbn1Ay8=", + "zh:2589d15db6a9a180ac1b021341c1225b9cae59a7d7003c05b787b91ccade504f", + "zh:446d9b1ae47dcf26b8ef49ea2ad3013a406adc33b24c7460d33c779021161f70", + "zh:44927f518b28a5a454a479e4c0eb8021709217cc51c4b1f8b51203cf8f8fa1c4", + "zh:521b1a2d757cf9d41ee3d412b6026b4483c905b92724b98372357bcb22ab43a3", + "zh:719b4f9608d394bc40e8e23d057e9693be2035dca48ad0bbc65e9025bd31c209", + "zh:7ea429e5541af0cd45e87ad9a6d5a56dd91e593413f24ef4935fa6a16930a1ed", + "zh:b6103766e0094296fcd63ac414597cff3d1e1f2491b7de7e8dfd8fc9ad915fd2", + "zh:c2f0ea3039ce2a2433d9839ce04cfdb98652f76c6ee3a69d389f4036785417f1", + "zh:d0da4cfccc05ec2a9e961be468e2e98cea12a331262d3e524a402150f4884db2", + "zh:d17d19cb0337d5e3677e969f09bf0d67dfe286d8e1f34d150430e0d076fda9e1", + "zh:d2972f77c2254759ed0d44fac72b48bfe5701d6b9327f49ce26ce4551cd42884", + "zh:dcec5e6970dbf90624dfe648df7e8b3fa2963d3c5cbd1e0748dbad9a2eea097e", + "zh:dfffb14a9818224ae7004dda8c259439afdc94aabe208793fe24d2c9760b2b14", + "zh:f03ee3c782533a2e04bccbdc2291e1922b6bfabcd03a5a1be4f8a97059bef4d1", + "zh:f809ab383cca0a5f83072981c64208cbd7fa67e986a86ee02dd2c82333221e32", ] } diff --git a/infrastructure/modules/cognito/README.md b/infrastructure/modules/cognito/README.md new file mode 100644 index 00000000..927dd6ef --- /dev/null +++ b/infrastructure/modules/cognito/README.md @@ -0,0 +1,171 @@ +# Cognito + +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. + +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 + +## 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]}]" + } + ] +} +``` + +## Current defaults inherited from the old module + +* `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 + +## 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: + +* identity providers +* resource servers +* user groups +* arbitrary client definitions beyond the curated `app_clients` OAuth client shape + +If those become required later, they can be added back with an explicit shared-resources use case. + + + + +## Requirements + +| Name | Version | +| ---- | ------- | +| [terraform](#requirement\_terraform) | >= 1.13 | +| [aws](#requirement\_aws) | >= 6.42 | +| [awscc](#requirement\_awscc) | >= 1.89 | + +## Providers + +| Name | Version | +| ---- | ------- | +| [aws](#provider\_aws) | 6.50.0 | + +## Modules + +| Name | Source | Version | +| ---- | ------ | ------- | +| [cognito](#module\_cognito) | lgallard/cognito-user-pool/aws | 4.0.2 | +| [this](#module\_this) | git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags | v2.6.0 | + +## Resources + +| Name | Type | +| ---- | ---- | +| [aws_cognito_user.bootstrap_users](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cognito_user) | resource | + +## Inputs + +| Name | Description | Type | Default | Required | +| ---- | ----------- | ---- | ------- | :------: | +| [acr](#input\_acr) | ACR attribute applied to bootstrap Cognito users. | `string` | `"AAL1_USERPASS"` | no | +| [amr](#input\_amr) | AMR attribute applied to bootstrap Cognito users. | `string` | `"USERPASS"` | no | +| [app\_clients](#input\_app\_clients) | List of Cognito application clients to create. This wrapper intentionally supports the shared-resources OAuth client pattern rather than the full upstream clients surface. |
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)
enable_propagate_additional_user_context_data = optional(bool, false)
id_token_validity = optional(number)
refresh_token_validity = optional(number)
prevent_user_existence_errors = optional(string)
enable_token_revocation = optional(bool, true)
}))
| `[]` | no | +| [attribute\_names](#input\_attribute\_names) | Compatibility list of simple string schema attributes. Used to derive string\_schemas when string\_schemas is empty. | `list(string)` |
[
"acr",
"amr",
"email",
"idassurancelevel",
"nhsid_nrbac_roles",
"bcss_username",
"sid",
"uid"
]
| no | +| [aws\_region](#input\_aws\_region) | AWS region used for derived Cognito hosted UI outputs. | `string` | `"eu-west-2"` | no | +| [bootstrap\_users](#input\_bootstrap\_users) | 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. |
list(object({
uuid = string
bcss_username = string
id_assurance_level = string
rbac_role = string
user_password = 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 | +| [create](#input\_create) | Determines whether Cognito resources will be created. | `bool` | `true` | no | +| [deletion\_protection](#input\_deletion\_protection) | Deletion protection setting for the user pool. Valid values are ACTIVE and INACTIVE. | `string` | `"INACTIVE"` | no | +| [domain](#input\_domain) | Optional Cognito user pool domain prefix. Defaults to name\_prefix or the resolved user pool name. | `string` | `null` | no | +| [enabled](#input\_enabled) | Set to false to prevent the module from creating any resources. | `bool` | `null` | no | +| [environment](#input\_environment) | Environment identifier used by the shared tags module. | `string` | `null` | no | +| [message\_action](#input\_message\_action) | Message action for bootstrap Cognito user creation. Defaults to SUPPRESS to match the current BCSS stacks. | `string` | `"SUPPRESS"` | no | +| [mfa\_configuration](#input\_mfa\_configuration) | MFA setting for the user pool. Valid values are ON, OFF, or OPTIONAL. | `string` | `"OFF"` | no | +| [name](#input\_name) | Name identifier used by the shared tags module. | `string` | `null` | no | +| [name\_prefix](#input\_name\_prefix) | Compatibility alias for older callers. Used as the default user pool and domain prefix when user\_pool\_name or domain are unset. | `string` | `null` | no | +| [project](#input\_project) | Project identifier used by the shared tags module. | `string` | `null` | no | +| [service](#input\_service) | Service identifier used by the shared tags module. | `string` | `null` | no | +| [tags](#input\_tags) | Additional tags merged with any tags supplied through the context object. | `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 | +| [user\_email](#input\_user\_email) | Email attribute applied to bootstrap Cognito users. | `string` | `"nhsdigital.axe@nhs.net"` | no | +| [user\_password](#input\_user\_password) | Fallback password for bootstrap Cognito users when an individual bootstrap\_users entry does not provide user\_password. | `string` | `"changeme"` | no | + +## Outputs + +| Name | Description | +| ---- | ----------- | +| [app\_client\_ids](#output\_app\_client\_ids) | Map of shared-resources app client names to client IDs. | +| [app\_client\_secrets](#output\_app\_client\_secrets) | Map of shared-resources app client names to client secrets. | +| [client\_ids](#output\_client\_ids) | IDs of any Cognito user pool clients created by this module. | +| [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\_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. | +| [user\_pool\_hosted\_ui\_url](#output\_user\_pool\_hosted\_ui\_url) | Hosted UI URL for the Cognito domain when a default domain prefix is configured. | +| [user\_pool\_id](#output\_user\_pool\_id) | ID of the Cognito user pool. | +| [user\_pool\_name](#output\_user\_pool\_name) | Name of the Cognito user pool. | + + + diff --git a/infrastructure/modules/cognito/context.tf b/infrastructure/modules/cognito/context.tf new file mode 100644 index 00000000..0e99c0b4 --- /dev/null +++ b/infrastructure/modules/cognito/context.tf @@ -0,0 +1,145 @@ +# 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 = "git::https://github.com/NHSDigital/screening-terraform-modules-aws.git//infrastructure/modules/tags?ref=v2.6.0" + + 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 +} + +variable "aws_region" { + type = string + 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" + } +} + +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 = "Service identifier used by the shared tags module." +} + +variable "project" { + type = string + default = null + description = "Project identifier used by the shared tags module." +} + +variable "environment" { + type = string + default = null + description = "Environment identifier used by the shared tags module." +} + +variable "name" { + type = string + default = null + description = "Name identifier used by the shared tags module." +} + +variable "tags" { + type = map(string) + default = {} + description = "Additional tags merged with any tags supplied through the context object." +} diff --git a/infrastructure/modules/cognito/main.tf b/infrastructure/modules/cognito/main.tf index c9fe18dc..0e8908cc 100644 --- a/infrastructure/modules/cognito/main.tf +++ b/infrastructure/modules/cognito/main.tf @@ -1,110 +1,114 @@ -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 = "!%^*-_+=" -} + 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") -resource "aws_secretsmanager_secret" "password" { - name = "${var.name_prefix}-cognito-user" - recovery_window_in_days = var.recovery_window - - dynamic "replica" { - for_each = var.secret_replication_regions - content { - region = replica.value - } + default_admin_create_user_config = { + allow_admin_create_user_only = false } -} - -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", - ] + default_email_configuration = { + email_sending_account = "COGNITO_DEFAULT" + } - deletion_protection = var.deletion_protection - mfa_configuration = var.mfa_configuration - name = var.name_prefix - username_attributes = [] + default_password_policy = { + minimum_length = 8 + require_lowercase = true + require_numbers = true + require_symbols = true + require_uppercase = true + temporary_password_validity_days = 7 + password_history_size = 0 # https://docs.aws.amazon.com/AWSCloudFormation/latest/TemplateReference/aws-properties-cognito-userpool-passwordpolicy.html#cfn-cognito-userpool-passwordpolicy-passwordhistorysize + } - account_recovery_setting { - recovery_mechanism { + default_recovery_mechanisms = [ + { name = "verified_email" priority = 1 - } - recovery_mechanism { + }, + { name = "verified_phone_number" priority = 2 } - } + ] - admin_create_user_config { - allow_admin_create_user_only = false + default_username_configuration = { + case_sensitive = false } - email_configuration { - email_sending_account = "COGNITO_DEFAULT" + default_verification_message_template = { + default_email_option = "CONFIRM_WITH_CODE" } - password_policy { - minimum_length = 8 - require_lowercase = false - require_numbers = false - require_symbols = false - require_uppercase = false - temporary_password_validity_days = 7 - } - dynamic "schema" { - for_each = var.attribute_names - content { + 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" - } + cognito_clients = [ + for client in var.app_clients : { + 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 = 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 = 60 + id_token_validity = try(client.id_token_validity, null) + refresh_token_validity = try(client.refresh_token_validity, null) + token_validity_units = { access_token = "minutes", id_token = "minutes", refresh_token = "days" } + prevent_user_existence_errors = try(client.prevent_user_existence_errors, null) + enable_token_revocation = try(client.enable_token_revocation, true) + } + ] } - -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 + + 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" "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 +118,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 c97cbac9..638bfc4d 100644 --- a/infrastructure/modules/cognito/outputs.tf +++ b/infrastructure/modules/cognito/outputs.tf @@ -1,14 +1,67 @@ output "user_pool_id" { - description = "ID of the Cognito user pool" - 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" { - description = "ARN of the Secrets Manager secret containing generated password" - 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" { - description = "Domain prefix configured for the Cognito user pool domain" - value = aws_cognito_user_pool_domain.main.domain + 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 ? "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_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/readme.md b/infrastructure/modules/cognito/readme.md deleted file mode 100644 index ad047a07..00000000 --- a/infrastructure/modules/cognito/readme.md +++ /dev/null @@ -1,102 +0,0 @@ -# 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. - -## 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.| -``` - -## Creating users - -Users for this Cognito client are managed via the users.csv file. The following values need to be -specified: - -| 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. | - -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. - - - - -## Requirements - -| Name | Version | -| ---- | ------- | -| [terraform](#requirement\_terraform) | >= 1.5.7 | -| [aws](#requirement\_aws) | >= 6.47.0 | -| [random](#requirement\_random) | >= 3.9.0 | - -## Providers - -| Name | Version | -| ---- | ------- | -| [aws](#provider\_aws) | 6.50.0 | -| [random](#provider\_random) | 3.9.0 | - -## Modules - -No modules. - -## Resources - -| Name | Type | -| ---- | ---- | -| [aws_cognito_user.cognito_user_creation](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cognito_user) | resource | -| [aws_cognito_user_pool.cognito_user_pool](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cognito_user_pool) | resource | -| [aws_cognito_user_pool_domain.main](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/cognito_user_pool_domain) | resource | -| [aws_secretsmanager_secret.password](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret) | resource | -| [aws_secretsmanager_secret_version.password](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/resources/secretsmanager_secret_version) | resource | -| [random_password.password](https://registry.terraform.io/providers/hashicorp/random/latest/docs/resources/password) | resource | -| [aws_ssm_parameter.cognito_users](https://registry.terraform.io/providers/hashicorp/aws/latest/docs/data-sources/ssm_parameter) | data source | - -## Inputs - -| Name | Description | Type | Default | Required | -| ---- | ----------- | ---- | ------- | :------: | -| [acr](#input\_acr) | Default ACR value for user attributes | `string` | `"AAL1_USERPASS"` | no | -| [amr](#input\_amr) | Default AMR value for user attributes | `string` | `"USERPASS"` | no | -| [attribute\_names](#input\_attribute\_names) | Cognito custom attributes to create | `list(string)` |
[
"acr",
"amr",
"email",
"idassurancelevel",
"nhsid_nrbac_roles",
"bss_username",
"sid",
"uid"
]
| no | -| [deletion\_protection](#input\_deletion\_protection) | Whether user pool deletion protection is enabled | `string` | `"INACTIVE"` | no | -| [environment](#input\_environment) | The name of the Environment this is deployed into, for example CICD, NFT, UAT or PROD | `string` | n/a | yes | -| [message\_action](#input\_message\_action) | Message action used when creating users | `string` | `"SUPPRESS"` | no | -| [mfa\_configuration](#input\_mfa\_configuration) | MFA mode for the user pool | `string` | `"OFF"` | no | -| [name\_prefix](#input\_name\_prefix) | The account, environment etc | `string` | n/a | yes | -| [recovery\_window](#input\_recovery\_window) | The number of days that credentials should be retained for | `number` | n/a | yes | -| [secret\_replication\_regions](#input\_secret\_replication\_regions) | List of additional regions where created secrets should be replicated | `list(string)` | n/a | yes | -| [user\_email](#input\_user\_email) | Initial user email address | `string` | `"nhsdigital.axe@nhs.net"` | no | -| [user\_password](#input\_user\_password) | Initial user password placeholder | `string` | `"changeme"` | no | - -## Outputs - -| Name | Description | -| ---- | ----------- | -| [secrets\_manager\_random\_passsword\_arn](#output\_secrets\_manager\_random\_passsword\_arn) | ARN of the Secrets Manager secret containing generated password | -| [user\_pool\_domain\_prefix](#output\_user\_pool\_domain\_prefix) | Domain prefix configured for the Cognito user pool domain | -| [user\_pool\_id](#output\_user\_pool\_id) | ID of the Cognito user pool | - - - diff --git a/infrastructure/modules/cognito/variables.tf b/infrastructure/modules/cognito/variables.tf index fee1d823..582406da 100644 --- a/infrastructure/modules/cognito/variables.tf +++ b/infrastructure/modules/cognito/variables.tf @@ -1,76 +1,128 @@ -#################################################################################### -# BSS COMMON -#################################################################################### +variable "create" { + description = "Determines whether Cognito resources will be created." + type = bool + default = true +} 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 } -# tflint-ignore: terraform_unused_declarations -variable "environment" { - description = "The name of the Environment this is deployed into, for example CICD, NFT, UAT or PROD" +variable "domain" { + description = "Optional Cognito user pool domain prefix. Defaults to name_prefix or the resolved user pool name." type = string + default = null } -################################################################################## -# COGNITO -################################################################################## variable "deletion_protection" { - description = "Whether user pool deletion protection is enabled" + 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" { - description = "MFA mode for the user pool" + 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" { - description = "Cognito custom attributes to create" + 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"] + default = ["acr", "amr", "email", "idassurancelevel", "nhsid_nrbac_roles", "bcss_username", "sid", "uid"] +} + +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) + enable_propagate_additional_user_context_data = optional(bool, false) + id_token_validity = optional(number) + refresh_token_validity = optional(number) + prevent_user_existence_errors = optional(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 "message_action" { - description = "Message action used when creating users" + 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" { - description = "Default ACR value for user attributes" + description = "ACR attribute applied to bootstrap Cognito users." type = string default = "AAL1_USERPASS" } variable "amr" { - description = "Default AMR value for user attributes" + description = "AMR attribute applied to bootstrap Cognito users." type = string default = "USERPASS" } variable "user_email" { - description = "Initial user email address" + description = "Email attribute applied to bootstrap Cognito users." type = string default = "nhsdigital.axe@nhs.net" } # tflint-ignore: terraform_unused_declarations variable "user_password" { - description = "Initial user password placeholder" + description = "Fallback password for bootstrap Cognito users when an individual bootstrap_users entry does not provide user_password." type = string default = "changeme" -} - -variable "recovery_window" { - description = "The number of days that credentials should be retained for" - type = number -} - -variable "secret_replication_regions" { - description = "List of additional regions where created secrets should be replicated" - type = list(string) + sensitive = true } diff --git a/infrastructure/modules/cognito/versions.tf b/infrastructure/modules/cognito/versions.tf index 2af6cc14..e6bd5b09 100644 --- a/infrastructure/modules/cognito/versions.tf +++ b/infrastructure/modules/cognito/versions.tf @@ -1,15 +1,18 @@ terraform { - required_version = ">= 1.5.7" + required_version = ">= 1.13" required_providers { aws = { source = "hashicorp/aws" - version = ">= 6.47.0" + version = ">= 6.42" } - random = { - source = "hashicorp/random" - version = ">= 3.9.0" + awscc = { + # Not used directly in this module + # Used by `lgallard/cognito-user-pool/aws` for managed login branding + # Pinned to the NHS platform provider baseline that supports this usage + source = "hashicorp/awscc" + version = ">= 1.89" } } } 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" + } + } +} 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..d07c78e4 --- /dev/null +++ b/infrastructure/modules/ecs-service/README.md @@ -0,0 +1,476 @@ +# 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 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 + +### Minimal Fargate service + +```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 = "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=" + + 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 = { + 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=" + + 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 = { + 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" + } + } + } + } +} +``` + +### 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 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 + 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. + + + + +## 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 | +| [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 | +| [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 | +| [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))
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 | +| [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 | +| [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` | `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 | +| [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 | +| [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 | +| [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 | +| [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` | `"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 | +| [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 | +| [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\_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 | +| [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 | +| [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 | +| [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 | +| [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 + +| 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/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/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 new file mode 100644 index 00000000..054ea646 --- /dev/null +++ b/infrastructure/modules/ecs-service/main.tf @@ -0,0 +1,140 @@ +################################################################ +# 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" + + create = module.this.enabled + name = local.service_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 + 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 + 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 + requires_compatibilities = var.requires_compatibilities + 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 + 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 + 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 + 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/outputs.tf b/infrastructure/modules/ecs-service/outputs.tf new file mode 100644 index 00000000..45595c45 --- /dev/null +++ b/infrastructure/modules/ecs-service/outputs.tf @@ -0,0 +1,124 @@ +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 +} diff --git a/infrastructure/modules/ecs-service/variables.tf b/infrastructure/modules/ecs-service/variables.tf new file mode 100644 index 00000000..88905d0a --- /dev/null +++ b/infrastructure/modules/ecs-service/variables.tf @@ -0,0 +1,1258 @@ +################################################################ +# 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({ + 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 +} + +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 = "" + + 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" { + 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)) + 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 +} + +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 +} + +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 +} + +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 = false +} + +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 +} + +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 +} + +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 +} + +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 +} + +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 = "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" { + description = "Configuration block for the App Mesh proxy" + type = object({ + container_name = string + properties = optional(map(string)) + type = optional(string) + }) + 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 +} + +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_name" { + description = "Name of the service" + type = 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 = [] +} + +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 = {} +} + +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 +} + +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 +} 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" + } + } +} diff --git a/infrastructure/modules/rds-database/.terraform.lock.hcl b/infrastructure/modules/rds-database/.terraform.lock.hcl index b40b96e5..c3c51676 100644 --- a/infrastructure/modules/rds-database/.terraform.lock.hcl +++ b/infrastructure/modules/rds-database/.terraform.lock.hcl @@ -28,29 +28,29 @@ 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=", + "h1:bvlWCSuVJQshwuA/vJONdjEUIzGdsW3uSfLxoP9RnIw=", + "h1:eD0xCJQCp+iQQKpU/SpMk/pGRrkF16UUJAEMCXvWCWo=", + "h1:nZ85OLLO0sNw/76mgQQQmnOodiQGaaGVxq1NaV3ol4g=", + "h1:sSfqLt0XIbqfTvOSZr2gkyiv1Ysg6uuiVXFd/btmsoY=", + "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", ] } 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 new file mode 100644 index 00000000..11e589ef --- /dev/null +++ b/infrastructure/modules/rds/README.md @@ -0,0 +1,395 @@ +# 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. 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| +|`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 + +### 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 +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 (optional) + # If omitted, the module derives a name from context labels. + 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_security_group_ids = [module.rds_security_group.security_group_id] + + # 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 +} +``` + +### With security-group and secrets-manager modules + +This example shows the recommended pattern for production use: integrating with the dedicated security-group and secrets-manager modules. + +```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 `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`. +- 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 + +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_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. +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 | +| [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 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 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 | +| [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 (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 (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 | +| [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 new file mode 100644 index 00000000..62befcb0 --- /dev/null +++ b/infrastructure/modules/rds/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/rds/locals.tf b/infrastructure/modules/rds/locals.tf new file mode 100644 index 00000000..27c39695 --- /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.identifier, module.this.id) +} diff --git a/infrastructure/modules/rds/main.tf b/infrastructure/modules/rds/main.tf new file mode 100644 index 00000000..30efc083 --- /dev/null +++ b/infrastructure/modules/rds/main.tf @@ -0,0 +1,91 @@ +# ---------------------------------------------------------------------------- +# 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" + + 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 + 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 = var.vpc_security_group_ids + + # Subnet group (always managed by this module) + 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 = module.this.enabled && 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..7d642c75 --- /dev/null +++ b/infrastructure/modules/rds/outputs.tf @@ -0,0 +1,70 @@ +# 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 +} + +# 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..7649da94 --- /dev/null +++ b/infrastructure/modules/rds/variables.tf @@ -0,0 +1,302 @@ +# ---------------------------------------------------------------------------- +# Instance identity +# ---------------------------------------------------------------------------- + +variable "identifier" { + 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 +} + +# ---------------------------------------------------------------------------- +# 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 + + 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)." + } +} + +# ---------------------------------------------------------------------------- +# 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 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." + } +} + +# ---------------------------------------------------------------------------- +# 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 + + 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" { + 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_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." + } +} + +# ---------------------------------------------------------------------------- +# 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 + + 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." + } +} + +# 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 (Standard mode of CloudWatch Database Insights). When true, performance_insights_kms_key_id must also be set." + type = bool + default = true +} + +variable "performance_insights_retention_period" { + description = "Retention period for Performance Insights data in days. Valid values: 7, 731 (2 years), 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 (2 years), or a multiple of 31 (e.g. 31, 62, 93)." + } +} + +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." + } +} + +# ---------------------------------------------------------------------------- +# 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..cb30fe5c --- /dev/null +++ b/infrastructure/modules/rds/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/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" + } + } +} diff --git a/scripts/config/generate-available-modules.yaml b/scripts/config/generate-available-modules.yaml index 41e5b7b9..05111041 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: "—" @@ -29,6 +33,22 @@ aws-scheduler: description: "EventBridge Scheduler configuration" wraps: "—" +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: "—"