diff --git a/.tool-versions b/.tool-versions index f4e08985..e3865432 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,6 +1,6 @@ # This file is for you! Please, updated to the versions agreed by your team. -terraform 1.7.0 +terraform 1.11.1 pre-commit 3.6.0 vale 3.6.0 poetry 2.1.1 diff --git a/infrastructure/Makefile b/infrastructure/Makefile new file mode 100644 index 00000000..079cb3cb --- /dev/null +++ b/infrastructure/Makefile @@ -0,0 +1,53 @@ +################### +## Utilities ## +################### +guard-%: + @ if [ "${${*}}" = "" ]; then \ + echo "Variable $* not set"; \ + exit 1; \ + fi + +################### +#### Terraform #### +################### + +# Initializes the Terraform configuration for the specified stack and environment. +terraform-init: guard-env guard-stack + rm -rf ./stacks/$(stack)/.terraform + terraform -chdir=./stacks/$(stack) init -var-file=stacks/_shared/tfvars/$(env).tfvars -backend-config=backends/$(env).$(stack).tfbackend -upgrade + terraform -chdir=./stacks/$(stack) get -update + +# Selects or creates a Terraform workspace for the specified stack and environment. +terraform-workspace: guard-env guard-stack guard-workspace + terraform -chdir=./stacks/$(stack) workspace select $(workspace) || \ + terraform -chdir=./stacks/$(stack) workspace new $(workspace) + + terraform -chdir=./stacks/$(stack) workspace show + +# Lists all Terraform workspaces for the specified stack and environment. +terraform-workspace-list: guard-env guard-stack terraform-init + terraform -chdir=./stacks/$(stack) workspace list + +# Deletes a specified Terraform workspace for the stack, switching to the default workspace first. +terraform-workspace-delete: guard-env guard-stack + terraform -chdir=./stacks/$(stack) workspace select default + terraform -chdir=./stacks/$(stack) workspace delete $(workspace) + +# Runs a specified Terraform command (e.g., plan, apply) for the stack and environment. +terraform: guard-env guard-stack guard-tf-command terraform-init terraform-workspace + terraform -chdir=./stacks/$(stack) $(tf-command) -var-file=../_shared/tfvars/$(env).tfvars $(args) --parallelism=30 + rm -f ./terraform_outputs_$(stack).json || true + terraform -chdir=./stacks/$(stack) output -json > ./build/terraform_outputs_$(stack).json + +################### +#### Bootstrap #### +################### + +# Initializes the Terraform configuration for the bootstrap stack. +bootstrap-terraform-init: guard-env + terraform -chdir=./stacks/bootstrap init -var-file=stacks/_shared/tfvars/$(env).tfvars -upgrade + terraform -chdir=./stacks/bootstrap get -update + +# Runs a specified Terraform command (e.g., plan, apply) for the bootstrap stack. +bootstrap-terraform: guard-env guard-tf-command bootstrap-terraform-init + terraform -chdir=./stacks/bootstrap $(tf-command) -var-file=../_shared/tfvars/$(env).tfvars $(args) diff --git a/infrastructure/modules/_shared/default_variables.tf b/infrastructure/modules/_shared/default_variables.tf new file mode 100644 index 00000000..38738be7 --- /dev/null +++ b/infrastructure/modules/_shared/default_variables.tf @@ -0,0 +1,18 @@ +# tflint-ignore: terraform_unused_declarations +variable "project_name" { + default = "eligibility-signposting-api" + type = string +} + +# tflint-ignore: terraform_unused_declarations +variable "environment" { + description = "The purpose of the account dev/test/ref/prod or the workspace" + type = string +} + +# tflint-ignore: terraform_unused_declarations +variable "tags" { + description = "A map of tags to assign to resources." + type = map(string) + default = {} +} diff --git a/infrastructure/modules/bootstrap/tfstate/default_variables.tf b/infrastructure/modules/bootstrap/tfstate/default_variables.tf new file mode 120000 index 00000000..0fcfde5c --- /dev/null +++ b/infrastructure/modules/bootstrap/tfstate/default_variables.tf @@ -0,0 +1 @@ +../../_shared/default_variables.tf \ No newline at end of file diff --git a/infrastructure/modules/bootstrap/tfstate/kms.tf b/infrastructure/modules/bootstrap/tfstate/kms.tf new file mode 100644 index 00000000..d488a8c6 --- /dev/null +++ b/infrastructure/modules/bootstrap/tfstate/kms.tf @@ -0,0 +1,12 @@ +resource "aws_kms_key" "terraform_state_bucket_cmk" { + description = "Terraform State Bucket Master Key" + deletion_window_in_days = 14 + is_enabled = true + enable_key_rotation = true + tags = var.tags +} + +resource "aws_kms_alias" "terraform_state_bucket_cmk" { + name = "alias/${var.project_name}-tfstate_bucket_cmk" + target_key_id = aws_kms_key.terraform_state_bucket_cmk.key_id +} diff --git a/infrastructure/modules/bootstrap/tfstate/providers.tf b/infrastructure/modules/bootstrap/tfstate/providers.tf new file mode 100644 index 00000000..4b9d1aa1 --- /dev/null +++ b/infrastructure/modules/bootstrap/tfstate/providers.tf @@ -0,0 +1,10 @@ +terraform { + required_version = ">= 1.11.1" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.6, != 5.71.0" + } + } +} diff --git a/infrastructure/modules/bootstrap/tfstate/s3.tf b/infrastructure/modules/bootstrap/tfstate/s3.tf new file mode 100644 index 00000000..0ecdc98d --- /dev/null +++ b/infrastructure/modules/bootstrap/tfstate/s3.tf @@ -0,0 +1,174 @@ +# Main state bucket +resource "aws_s3_bucket" "tfstate_bucket" { + bucket = "${var.project_name}-${var.environment}-tfstate" + tags = { + Stack = "Bootstrap" + } +} + +# Enable versioning for disaster recovery +resource "aws_s3_bucket_versioning" "tfstate_bucket_versioning_config" { + bucket = aws_s3_bucket.tfstate_bucket.id + versioning_configuration { + status = "Enabled" + } +} +# Block public access to the bucket +resource "aws_s3_bucket_public_access_block" "tfstate" { + bucket = aws_s3_bucket.tfstate_bucket.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +# Encrypt the bucket with a KMS key +resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate_bucket_server_side_encryption_config" { + bucket = aws_s3_bucket.tfstate_bucket.id + + rule { + apply_server_side_encryption_by_default { + kms_master_key_id = aws_kms_key.terraform_state_bucket_cmk.arn + sse_algorithm = "aws:kms" + } + bucket_key_enabled = true + } +} + +resource "aws_s3_bucket_policy" "tfstate_bucket" { + bucket = aws_s3_bucket.tfstate_bucket.id + policy = data.aws_iam_policy_document.tfstate_s3_bucket_policy.json +} + +data "aws_iam_policy_document" "tfstate_s3_bucket_policy" { + statement { + sid = "AllowSslRequestsOnly" + actions = [ + "s3:*", + ] + effect = "Deny" + resources = [ + aws_s3_bucket.tfstate_bucket.arn, + "${aws_s3_bucket.tfstate_bucket.arn}/*", + ] + principals { + type = "*" + identifiers = ["*"] + } + condition { + test = "Bool" + values = [ + "false", + ] + + variable = "aws:SecureTransport" + } + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "tfstate_bucket" { + bucket = aws_s3_bucket.tfstate_bucket.id + + rule { + id = "TfStateBucketExpirationTransferToIa" + status = "Enabled" + filter { + prefix = "" + } + + expiration { + days = 90 + } + + noncurrent_version_transition { + noncurrent_days = 30 + storage_class = "STANDARD_IA" + } + + abort_incomplete_multipart_upload { + days_after_initiation = 7 + } + } +} + +# Logging + +resource "aws_s3_bucket" "tfstate_s3_access_logs" { + bucket = "${var.project_name}-${var.environment}-tfstate-access-logs" +} + +resource "aws_s3_bucket_logging" "s3_logging_config" { + bucket = aws_s3_bucket.tfstate_bucket.id + target_bucket = aws_s3_bucket.tfstate_s3_access_logs.bucket + target_prefix = "bucket_logs/" +} + +resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate_s3_access_logs_server_side_encryption_config" { + bucket = aws_s3_bucket.tfstate_s3_access_logs.id + + rule { + apply_server_side_encryption_by_default { + sse_algorithm = "AES256" + } + } +} + +resource "aws_s3_bucket_lifecycle_configuration" "tfstate_s3_access_logs_object_expiry_lifecycle_rule_config" { + bucket = aws_s3_bucket.tfstate_s3_access_logs.id + + rule { + id = "StateBucketLogsExpiration" + status = "Enabled" + filter { + prefix = "" + } + expiration { + days = var.log_retention_in_days + } + + noncurrent_version_expiration { + noncurrent_days = var.log_retention_in_days + } + } +} + +resource "aws_s3_bucket_public_access_block" "s3logs" { + bucket = aws_s3_bucket.tfstate_s3_access_logs.id + + block_public_acls = true + block_public_policy = true + ignore_public_acls = true + restrict_public_buckets = true +} + +resource "aws_s3_bucket_policy" "tfstate_s3_access_logs_bucket_policy" { + bucket = aws_s3_bucket.tfstate_s3_access_logs.id + policy = data.aws_iam_policy_document.tfstate_s3_access_logs_bucket_policy.json +} + +data "aws_iam_policy_document" "tfstate_s3_access_logs_bucket_policy" { + statement { + sid = "AllowSSLRequestsOnly" + actions = [ + "s3:*", + ] + effect = "Deny" + resources = [ + aws_s3_bucket.tfstate_s3_access_logs.arn, + "${aws_s3_bucket.tfstate_s3_access_logs.arn}/*", + ] + principals { + type = "*" + identifiers = ["*"] + } + condition { + test = "Bool" + values = [ + "false", + ] + + variable = "aws:SecureTransport" + } + } +} diff --git a/infrastructure/modules/bootstrap/tfstate/variables.tf b/infrastructure/modules/bootstrap/tfstate/variables.tf new file mode 100644 index 00000000..88f1b1fa --- /dev/null +++ b/infrastructure/modules/bootstrap/tfstate/variables.tf @@ -0,0 +1,5 @@ +# tflint-ignore: terraform_unused_declarations +variable "log_retention_in_days" { + default = 5 + type = number +} diff --git a/infrastructure/stacks/_shared/default_variables.tf b/infrastructure/stacks/_shared/default_variables.tf new file mode 100644 index 00000000..aff295ee --- /dev/null +++ b/infrastructure/stacks/_shared/default_variables.tf @@ -0,0 +1,11 @@ +# tflint-ignore: terraform_unused_declarations +variable "project_name" { + default = "eligibility-signposting-api" + type = string +} + +variable "environment" { + default = "dev" + description = "Environment" + type = string +} diff --git a/infrastructure/stacks/_shared/locals.tf b/infrastructure/stacks/_shared/locals.tf new file mode 100644 index 00000000..64c7b549 --- /dev/null +++ b/infrastructure/stacks/_shared/locals.tf @@ -0,0 +1,18 @@ +locals { + # tflint-ignore: terraform_unused_declarations + environment = var.environment + # tflint-ignore: terraform_unused_declarations + workspace = lower(terraform.workspace) + # tflint-ignore: terraform_unused_declarations + runtime = "python3.13.1" + + # tflint-ignore: terraform_unused_declarations + tags = { + TagVersion = "1" + Programme = "Vaccinations" + Project = "EligibilitySignpostingAPI" + Environment = var.environment + ServiceCategory = var.environment == "prod" ? "Bronze" : "N/A" + Tool = "Terraform" + } +} diff --git a/infrastructure/stacks/bootstrap/README.md b/infrastructure/stacks/bootstrap/README.md new file mode 100644 index 00000000..055bce0c --- /dev/null +++ b/infrastructure/stacks/bootstrap/README.md @@ -0,0 +1,139 @@ +# Provisioning a New AWS Account + +Adapted from the [demographics serverless template](https://github.com/NHSDigital/demographics-serverless-template). + +This guide explains how to initialize an AWS environment for use with Terraform. Specifically, it covers creating an S3 bucket for storing Terraform state (`.tfstate` file) and enabling state locking (`.tflock` file). + +The Terraform code for this process is defined in the `bootstrap` module (`infrastructure/modules/bootstrap`) and invoked within the `bootstrap` stack (`infrastructure/stacks/bootstrap`). + +This step should only need to be performed *once* per environment. + +## Prerequisites + +- **Terraform Installed**: Ensure Terraform is installed locally (see project prerequisites). +- **AWS Admin Role**: Access to an admin role for the AWS account where Terraform changes will be applied. You need to have + credentials set up to run this locally. +- **Environment-Specific Variables**: A `tfvars` file must exist for the environment in `infrastructure/stacks/_shared/tfvars`. + +--- + +## 1. Running the Bootstrap Stack + +### 1.1 Temporarily Disable the Backend Configuration + +The bootstrap process creates the S3 bucket for storing Terraform state. Initially, we run the configuration without specifying an AWS backend (state is stored locally). Once the bucket is created, we enable the backend configuration to store state in S3. + +Edit `infrastructure/stacks/bootstrap/state.tf` to comment out the backend block: + +```hcl +terraform { + required_version = ">= 1.11.1" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.6, != 5.71.0" + } + } + # backend "s3" { + # bucket = "eligibility-signposting-api-dev-tfstate" + # key = "tfstate/terraform.tfstate" + # region = "eu-west-2" + # use_lockfile = true + # } +} +``` + +### 1.2 Initialize Terraform and Plan + +Run the following command to initialize Terraform and generate a plan. Replace `` with the target environment: + +```bash +make bootstrap-terraform env= tf-command=plan +``` + +**Note**: If initialization fails, delete the following files and retry: + +- `infrastructure/stacks/bootstrap/.terraform` +- `infrastructure/stacks/bootstrap/.terraform.lock.hcl` + +### 1.3 Create a Workspace + +Workspaces allow for alternative deployments within the same environment (e.g., testing changes in `dev`). Create a workspace with the same name as the environment: + +```bash +make terraform-workspace env= stack=bootstrap workspace= +``` + +### 1.4 Apply Terraform Changes + +Deploy the Terraform configuration using the following command: + +```bash +make bootstrap-terraform env= tf-command=apply args="-auto-approve=true" +``` + +--- + +## 2. Push Local State to the Remote S3 Bucket + +### 2.1 Enable the Backend Configuration + +Uncomment the backend block in `infrastructure/stacks/bootstrap/state.tf`: + +```hcl +terraform { + required_version = ">= 1.11.1" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.6, != 5.71.0" + } + } + backend "s3" { + bucket = "eligibility-signposting-api-dev-tfstate" + key = "tfstate/terraform.tfstate" + region = "eu-west-2" + use_lockfile = true + } +} +``` + +### 2.2 Reinitialize Terraform with the Backend + +Reinitialize Terraform to migrate the state to the S3 bucket: + +```bash +make terraform env= workspace= stack=bootstrap tf-command=apply +``` + +You will see a prompt like the following: + +```bash +Do you want to migrate all workspaces to "s3"? [yes/no] +``` + +Type `yes` to push the local state to the remote S3 bucket. + +--- + +## 3. Delete the Default VPC + +The default VPC should be deleted from each region. This can be done via the AWS Console: + +1. Navigate to the **VPC** service. +2. Select the default VPC (ensure the "Default" column is marked "yes"). +3. Click **Actions** > **Delete VPC** and confirm the deletion. + +--- + +## Notes + +- Use `args=` to target specific modules during Terraform commands, e.g., `args="-target=module.tfstate -target=module.terraform_base_role"`. +- Always verify the environment and workspace before applying changes to avoid accidental modifications. +- If you want to test a new Terraform configuration in `dev`, set up a workspace linked to your branch/PR (e.g., `dev-PR123`), and then deploy according to the instructions. + +--- + +This guide ensures a smooth setup of the Terraform backend and state management for new AWS accounts. Let us know if you encounter any issues! diff --git a/infrastructure/stacks/bootstrap/backends/dev.bootstrap.tfbackend b/infrastructure/stacks/bootstrap/backends/dev.bootstrap.tfbackend new file mode 100644 index 00000000..56ed9146 --- /dev/null +++ b/infrastructure/stacks/bootstrap/backends/dev.bootstrap.tfbackend @@ -0,0 +1,4 @@ +bucket = "eligibility-signposting-api-dev-tfstate" +key = "terraform.tfstate" +region = "eu-west-2" +encrypt = true diff --git a/infrastructure/stacks/bootstrap/default_variables.tf b/infrastructure/stacks/bootstrap/default_variables.tf new file mode 120000 index 00000000..062daf61 --- /dev/null +++ b/infrastructure/stacks/bootstrap/default_variables.tf @@ -0,0 +1 @@ +../_shared/default_variables.tf \ No newline at end of file diff --git a/infrastructure/stacks/bootstrap/locals.tf b/infrastructure/stacks/bootstrap/locals.tf new file mode 120000 index 00000000..e360bc7f --- /dev/null +++ b/infrastructure/stacks/bootstrap/locals.tf @@ -0,0 +1 @@ +../_shared/locals.tf \ No newline at end of file diff --git a/infrastructure/stacks/bootstrap/modules.tf b/infrastructure/stacks/bootstrap/modules.tf new file mode 100644 index 00000000..72e8b6e7 --- /dev/null +++ b/infrastructure/stacks/bootstrap/modules.tf @@ -0,0 +1,6 @@ +module "tfstate" { + source = "../../modules/bootstrap/tfstate" + + project_name = var.project_name + environment = var.environment +} diff --git a/infrastructure/stacks/bootstrap/provider.tf b/infrastructure/stacks/bootstrap/provider.tf new file mode 100644 index 00000000..4dacca02 --- /dev/null +++ b/infrastructure/stacks/bootstrap/provider.tf @@ -0,0 +1,7 @@ +provider "aws" { + region = "eu-west-2" + + default_tags { + tags = local.tags + } +} diff --git a/infrastructure/stacks/bootstrap/state.tf b/infrastructure/stacks/bootstrap/state.tf new file mode 100644 index 00000000..6457159c --- /dev/null +++ b/infrastructure/stacks/bootstrap/state.tf @@ -0,0 +1,16 @@ +terraform { + required_version = ">= 1.11.1" + + required_providers { + aws = { + source = "hashicorp/aws" + version = "~> 5.6, != 5.71.0" + } + } + backend "s3" { + bucket = "eligibility-signposting-api-dev-tfstate" + key = "tfstate/terraform.tfstate" + region = "eu-west-2" + use_lockfile = true + } +} diff --git a/scripts/config/vale/styles/config/vocabularies/words/accept.txt b/scripts/config/vale/styles/config/vocabularies/words/accept.txt index d4e7d15e..9677cf4a 100644 --- a/scripts/config/vale/styles/config/vocabularies/words/accept.txt +++ b/scripts/config/vale/styles/config/vocabularies/words/accept.txt @@ -16,6 +16,7 @@ Syft Terraform toolchain Trufflehog +Uncomment Syncytial pyenv colima