diff --git a/.github/workflows/account-terraform.yml b/.github/workflows/account-terraform.yml new file mode 100644 index 000000000..871abcb89 --- /dev/null +++ b/.github/workflows/account-terraform.yml @@ -0,0 +1,228 @@ +name: Apply Account Terraform + +on: + workflow_call: + inputs: + base_sha: + required: true + type: string + head_sha: + required: true + type: string + environment: + required: true + type: string + state_bucket_environment: + required: false + type: string + default: "" + artifact_name: + required: true + type: string + workflow_dispatch: + inputs: + environment: + description: Select AWS account environment + required: true + type: choice + options: + - dev + - preprod + - prod + state_bucket_environment: + description: Override state bucket environment + required: false + type: string + default: "" + base_sha: + description: Base commit SHA for diff checks. Leave blank to use previous commit. + required: false + type: string + default: "" + head_sha: + description: Head commit SHA for diff checks. Leave blank to use current commit. + required: false + type: string + default: "" + artifact_name: + description: Optional Terraform plan artifact name + required: false + type: string + default: "" + +run-name: Apply Account Terraform - ${{ inputs.environment }} + +concurrency: + group: account-terraform-${{ github.repository }}-${{ inputs.environment }} + cancel-in-progress: false + +env: + CONFIGURED_ACCOUNT_TERRAFORM_STATE_BUCKET: ${{ vars.ACCOUNT_TERRAFORM_STATE_BUCKET || (inputs.environment == 'dev' && 'immunisation-terraform-state-files' || '') }} + ACCOUNT_TERRAFORM_STATE_ENVIRONMENT: ${{ inputs.state_bucket_environment }} + ACCOUNT_TERRAFORM_ARTIFACT_NAME: ${{ inputs.artifact_name || format('{0}-account-tfplan-{1}', inputs.environment, github.run_attempt) }} + ACCOUNT_TERRAFORM_VERSION: "1.12.2" + +jobs: + account-terraform-plan: + permissions: + id-token: write + contents: read + attestations: write + artifact-metadata: write + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: ${{ inputs.environment }} + env: + ACCOUNT_TERRAFORM_BASE_SHA: ${{ inputs.base_sha }} + ACCOUNT_TERRAFORM_HEAD_SHA: ${{ inputs.head_sha || github.sha }} + ACCOUNT_TERRAFORM_ENVIRONMENT: ${{ inputs.environment }} + outputs: + account_infra_changed: ${{ steps.diff.outputs.account_infra_changed }} + plan_sha: ${{ env.ACCOUNT_TERRAFORM_HEAD_SHA }} + steps: + - name: Checkout + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + fetch-depth: 0 + + - name: Detect account terraform changes + id: diff + run: | + base_sha="$ACCOUNT_TERRAFORM_BASE_SHA" + head_sha="$ACCOUNT_TERRAFORM_HEAD_SHA" + + if [[ -z "$base_sha" || "$base_sha" == "0000000000000000000000000000000000000000" ]]; then + base_sha=$(git rev-parse HEAD~1) + fi + + for sha_name in base_sha head_sha; do + if [[ ! "${!sha_name}" =~ ^[0-9a-f]{40}$ ]]; then + echo "Invalid $sha_name: ${!sha_name}" >&2 + exit 1 + fi + done + + account_changed_files=$(git diff --name-only "$base_sha" "$head_sha" -- infrastructure/account) + if [ -n "$account_changed_files" ]; then + echo "changes detected in files:" + printf '%s\n' "$account_changed_files" + fi + echo "account_infra_changed=$( [ -n "$account_changed_files" ] && echo true || echo false )" >> "$GITHUB_OUTPUT" + + - name: Connect to AWS + if: ${{ steps.diff.outputs.account_infra_changed == 'true' }} + uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 + with: + aws-region: eu-west-2 + role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/auto-ops + role-session-name: ${{ format('github-actions-{0}-{1}-{2}', github.run_id, github.run_attempt, github.job) }} + + - uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 + if: ${{ steps.diff.outputs.account_infra_changed == 'true' }} + with: + terraform_version: ${{ env.ACCOUNT_TERRAFORM_VERSION }} + + - name: Resolve account terraform state bucket + id: account-state-bucket + if: ${{ steps.diff.outputs.account_infra_changed == 'true' }} + run: echo "bucket_name=$(bash ./utilities/scripts/resolve_account_terraform_state_bucket.sh)" >> "$GITHUB_OUTPUT" + + - name: Terraform Init (account) + if: ${{ steps.diff.outputs.account_infra_changed == 'true' }} + working-directory: infrastructure/account + env: + ACCOUNT_TERRAFORM_BUCKET_NAME: ${{ steps.account-state-bucket.outputs.bucket_name }} + run: make init ENVIRONMENT="$ACCOUNT_TERRAFORM_ENVIRONMENT" BUCKET_NAME="$ACCOUNT_TERRAFORM_BUCKET_NAME" + + - name: Terraform Plan (account) + # Ignore cancellations to prevent Terraform from being killed while it holds a state lock + # A stuck process can still be killed with the force-cancel API operation + if: ${{ steps.diff.outputs.account_infra_changed == 'true' && !failure() }} + working-directory: infrastructure/account + run: make plan-ci ENVIRONMENT="$ACCOUNT_TERRAFORM_ENVIRONMENT" + + - name: Save Account Terraform Plan + if: ${{ steps.diff.outputs.account_infra_changed == 'true' }} + uses: actions/upload-artifact@bbbca2ddaa5d8feaa63e36b76fdaad77386f024f + with: + name: ${{ env.ACCOUNT_TERRAFORM_ARTIFACT_NAME }} + path: infrastructure/account/tfplan + + - name: Attest Account Terraform Plan + if: ${{ steps.diff.outputs.account_infra_changed == 'true' }} + uses: actions/attest@v4 + with: + subject-path: infrastructure/account/tfplan + + account-terraform-approval: + permissions: {} + needs: [account-terraform-plan] + if: ${{ !cancelled() && needs.account-terraform-plan.result == 'success' && needs.account-terraform-plan.outputs.account_infra_changed == 'true' }} + runs-on: ubuntu-latest + environment: + name: account-apply-${{ inputs.environment }} + steps: + - name: Await manual approval + run: echo "Manual approval granted" + + account-terraform-apply: + permissions: + id-token: write + contents: read + attestations: read + needs: [account-terraform-plan, account-terraform-approval] + if: ${{ !cancelled() && needs.account-terraform-plan.result == 'success' && needs.account-terraform-plan.outputs.account_infra_changed == 'true' && needs.account-terraform-approval.result == 'success' }} + runs-on: ubuntu-latest + timeout-minutes: 30 + environment: + name: ${{ inputs.environment }} + env: + ACCOUNT_TERRAFORM_ENVIRONMENT: ${{ inputs.environment }} + steps: + - name: Checkout + uses: actions/checkout@0c366fd6a839edf440554fa01a7085ccba70ac98 + with: + ref: ${{ needs.account-terraform-plan.outputs.plan_sha }} + + - name: Connect to AWS + uses: aws-actions/configure-aws-credentials@8df5847569e6427dd6c4fb1cf565c83acfa8afa7 + with: + aws-region: eu-west-2 + role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/auto-ops + role-session-name: ${{ format('github-actions-{0}-{1}-{2}', github.run_id, github.run_attempt, github.job) }} + + - uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 + with: + terraform_version: ${{ env.ACCOUNT_TERRAFORM_VERSION }} + + - name: Resolve account terraform state bucket + id: account-state-bucket + run: echo "bucket_name=$(bash ./utilities/scripts/resolve_account_terraform_state_bucket.sh)" >> "$GITHUB_OUTPUT" + + - name: Retrieve Account Terraform Plan + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c + with: + name: ${{ env.ACCOUNT_TERRAFORM_ARTIFACT_NAME }} + path: infrastructure/account + + - name: Verify Account Terraform Plan Attestation + env: + GH_TOKEN: ${{ github.token }} + run: | + gh attestation verify infrastructure/account/tfplan \ + --repo "$GITHUB_REPOSITORY" \ + --signer-workflow "$GITHUB_REPOSITORY/.github/workflows/account-terraform.yml" + + - name: Terraform Init (account) + working-directory: infrastructure/account + env: + ACCOUNT_TERRAFORM_BUCKET_NAME: ${{ steps.account-state-bucket.outputs.bucket_name }} + run: make init ENVIRONMENT="$ACCOUNT_TERRAFORM_ENVIRONMENT" BUCKET_NAME="$ACCOUNT_TERRAFORM_BUCKET_NAME" + + - name: Terraform Apply (account) + # Ignore cancellations to prevent Terraform from being killed while it holds a state lock + # A stuck process can still be killed with the force-cancel API operation + if: ${{ !failure() }} + working-directory: infrastructure/account + run: make apply-ci ENVIRONMENT="$ACCOUNT_TERRAFORM_ENVIRONMENT" diff --git a/.github/workflows/pr-deploy-and-test.yml b/.github/workflows/pr-deploy-and-test.yml index 573b15a0c..83c82a277 100644 --- a/.github/workflows/pr-deploy-and-test.yml +++ b/.github/workflows/pr-deploy-and-test.yml @@ -21,7 +21,7 @@ jobs: with: apigee_environment: internal-dev build_recordprocessor_image: ${{ github.event.action == 'opened' || github.event.action == 'reopened' }} - diff_base_sha: ${{ github.event.before }} + diff_base_sha: ${{ github.event.action == 'synchronize' && github.event.before || github.event.pull_request.base.sha }} diff_head_sha: ${{ github.event.pull_request.head.sha }} run_diff_check: ${{ github.event.action == 'synchronize' }} create_mns_subscription: true diff --git a/infrastructure/account/Makefile b/infrastructure/account/Makefile index fd539f57e..a2d46dd82 100644 --- a/infrastructure/account/Makefile +++ b/infrastructure/account/Makefile @@ -6,6 +6,13 @@ tf_cmd = AWS_PROFILE=$(AWS_PROFILE) terraform tf_state= -backend-config="bucket=$(BUCKET_NAME)" tf_vars= -var-file=environments/$(ENVIRONMENT)/variables.tfvars +define require_bucket_name + @if [ -z "$(strip $(BUCKET_NAME))" ]; then \ + echo "BUCKET_NAME variable not set. Use 'make init ENVIRONMENT=... BUCKET_NAME=...'"; \ + exit 1; \ + fi +endef + .PHONY: lock-provider workspace init plan apply clean destroy output tf-% lock-provider: @@ -17,17 +24,25 @@ workspace: $(tf_cmd) workspace select -or-create $(ENVIRONMENT) && echo "Switched to workspace/environment: $(ENVIRONMENT)" init: + $(require_bucket_name) $(tf_cmd) init $(tf_state) -upgrade $(tf_vars) init-reconfigure: + $(require_bucket_name) $(tf_cmd) init $(tf_state) -upgrade $(tf_vars) -reconfigure plan: workspace $(tf_cmd) plan $(tf_vars) +plan-ci: workspace + $(tf_cmd) plan $(tf_vars) -out=tfplan -input=false + apply: workspace $(tf_cmd) apply $(tf_vars) -auto-approve +apply-ci: workspace + $(tf_cmd) apply -input=false tfplan + clean: rm -rf build .terraform upload-key diff --git a/infrastructure/account/auto_ops_policy.json b/infrastructure/account/auto_ops_policy.json index a3ae6e12e..1a8b6bd1e 100644 --- a/infrastructure/account/auto_ops_policy.json +++ b/infrastructure/account/auto_ops_policy.json @@ -214,6 +214,7 @@ "iam:DeleteGroupPolicy", "iam:ListMFADeviceTags", "elasticache:*", + "shield:*", "iam:DeletePolicyVersion", "chatbot:*" ], diff --git a/infrastructure/account/recordprocessor_ecr_repo.tf b/infrastructure/account/recordprocessor_ecr_repo.tf index 9aaef8bfc..4f2d635ea 100644 --- a/infrastructure/account/recordprocessor_ecr_repo.tf +++ b/infrastructure/account/recordprocessor_ecr_repo.tf @@ -14,7 +14,7 @@ resource "aws_ecr_lifecycle_policy" "recordprocessor_repository_lifecycle_policy "rules": [ { "rulePriority": 1, - "description": "Keep last 10 images", + "description": "Keep last 10 images.", "selection": { "tagStatus": "any", "countType": "imageCountMoreThan", diff --git a/utilities/scripts/resolve_account_terraform_state_bucket.sh b/utilities/scripts/resolve_account_terraform_state_bucket.sh new file mode 100644 index 000000000..89b83ce0a --- /dev/null +++ b/utilities/scripts/resolve_account_terraform_state_bucket.sh @@ -0,0 +1,24 @@ +#!/bin/bash + +set -euo pipefail + +read -r configured_bucket <<< "${CONFIGURED_ACCOUNT_TERRAFORM_STATE_BUCKET:-}" +read -r state_bucket_environment <<< "${ACCOUNT_TERRAFORM_STATE_ENVIRONMENT:-}" + +[ -n "$configured_bucket" ] && printf '%s\n' "$configured_bucket" && exit 0 + +[ -n "$state_bucket_environment" ] || { + echo "ACCOUNT_TERRAFORM_STATE_ENVIRONMENT must be set when ACCOUNT_TERRAFORM_STATE_BUCKET is not configured." >&2 + exit 1 +} + +case "$state_bucket_environment" in + internal-dev|internal-qa|preprod|prod) + ;; + *) + echo "ACCOUNT_TERRAFORM_STATE_ENVIRONMENT must be one of: internal-dev, internal-qa, preprod, prod." >&2 + exit 1 + ;; +esac + +printf 'immunisation-%s-terraform-state-files\n' "$state_bucket_environment"