diff --git a/.github/workflows/deploy-backend.yml b/.github/workflows/deploy-backend.yml index b9790a68b..191208dec 100644 --- a/.github/workflows/deploy-backend.yml +++ b/.github/workflows/deploy-backend.yml @@ -42,19 +42,24 @@ on: type: string description: Set the sub environment name e.g. pr-xxx, or green/blue in higher environments +env: # Sonarcloud - do not allow direct usage of untrusted data + APIGEE_ENVIRONMENT: ${{ inputs.apigee_environment }} + ENVIRONMENT: ${{ inputs.environment }} + SUB_ENVIRONMENT: ${{ inputs.sub_environment }} + +permissions: + id-token: write + contents: read + jobs: terraform-plan: runs-on: ubuntu-latest environment: name: ${{ inputs.environment }} - env: # Sonarcloud - do not allow direct usage of untrusted data - APIGEE_ENVIRONMENT: ${{ inputs.apigee_environment }} - BACKEND_ENVIRONMENT: ${{ inputs.environment }} - BACKEND_SUB_ENVIRONMENT: ${{ inputs.sub_environment }} - permissions: - id-token: write - contents: read steps: + - name: Checkout + uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + - name: Connect to AWS uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 with: @@ -62,42 +67,29 @@ jobs: role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/auto-ops role-session-name: github-actions - - name: Whoami - run: aws sts get-caller-identity - - - name: Checkout - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 - - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd with: terraform_version: "1.12.2" - name: Terraform Init - working-directory: ${{ vars.TERRAFORM_DIR_PATH }} - run: make init apigee_environment=$APIGEE_ENVIRONMENT environment=$BACKEND_ENVIRONMENT sub_environment=$BACKEND_SUB_ENVIRONMENT + working-directory: terraform + run: make init - name: Terraform Plan - working-directory: ${{ vars.TERRAFORM_DIR_PATH }} - run: make plan-ci apigee_environment=$APIGEE_ENVIRONMENT environment=$BACKEND_ENVIRONMENT sub_environment=$BACKEND_SUB_ENVIRONMENT + working-directory: terraform + run: make plan-ci - name: Save Terraform Plan uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 with: name: tfplan - path: ${{ vars.TERRAFORM_DIR_PATH }}/tfplan + path: terraform/tfplan terraform-apply: needs: terraform-plan runs-on: ubuntu-latest environment: name: ${{ inputs.environment }} - env: # Sonarcloud - do not allow direct usage of untrusted data - APIGEE_ENVIRONMENT: ${{ inputs.apigee_environment }} - BACKEND_ENVIRONMENT: ${{ inputs.environment }} - BACKEND_SUB_ENVIRONMENT: ${{ inputs.sub_environment }} - permissions: - id-token: write - contents: read steps: - name: Checkout uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 @@ -116,16 +108,16 @@ jobs: uses: actions/download-artifact@634f93cb2916e3fdff6788551b99b062d0335ce0 with: name: tfplan - path: ${{ vars.TERRAFORM_DIR_PATH }} + path: terraform - name: Terraform Init - working-directory: ${{ vars.TERRAFORM_DIR_PATH }} - run: make init apigee_environment=$APIGEE_ENVIRONMENT environment=$BACKEND_ENVIRONMENT sub_environment=$BACKEND_SUB_ENVIRONMENT + working-directory: terraform + run: make init - name: Terraform Apply - working-directory: ${{ vars.TERRAFORM_DIR_PATH }} + working-directory: terraform run: | - make apply-ci apigee_environment=$APIGEE_ENVIRONMENT environment=$BACKEND_ENVIRONMENT sub_environment=$BACKEND_SUB_ENVIRONMENT + make apply-ci echo "ID_SYNC_QUEUE_ARN=$(make -s output name=id_sync_queue_arn)" >> $GITHUB_ENV - name: Install poetry @@ -137,12 +129,14 @@ jobs: with: python-version: 3.11 cache: "poetry" + cache-dependency-path: | + lambdas/mns_subscription/poetry.lock + lambdas/shared/poetry.lock - name: Create MNS Subscription if: ${{ inputs.environment == 'dev' && inputs.create_mns_subscription }} working-directory: "./lambdas/mns_subscription" env: - APIGEE_ENVIRONMENT: ${{ inputs.apigee_environment }} SQS_ARN: ${{ env.ID_SYNC_QUEUE_ARN }} run: | poetry install --no-root diff --git a/.github/workflows/pr-teardown.yml b/.github/workflows/pr-teardown.yml index 8830234ff..4a0d2691d 100644 --- a/.github/workflows/pr-teardown.yml +++ b/.github/workflows/pr-teardown.yml @@ -43,7 +43,7 @@ jobs: terraform_version: "1.12.2" - name: Terraform Init and extract MNS SQS QUEUE ARN - working-directory: ${{ vars.TERRAFORM_DIR_PATH }} + working-directory: terraform run: | make init apigee_environment=$APIGEE_ENVIRONMENT environment=$BACKEND_ENVIRONMENT sub_environment=$BACKEND_SUB_ENVIRONMENT make workspace apigee_environment=$APIGEE_ENVIRONMENT environment=$BACKEND_ENVIRONMENT sub_environment=$BACKEND_SUB_ENVIRONMENT @@ -56,6 +56,9 @@ jobs: with: python-version: 3.11 cache: "poetry" + cache-dependency-path: | + lambdas/mns_subscription/poetry.lock + lambdas/shared/poetry.lock - name: Unsubscribe MNS working-directory: "./lambdas/mns_subscription" @@ -68,6 +71,6 @@ jobs: make unsubscribe - name: Terraform Destroy - working-directory: ${{ vars.TERRAFORM_DIR_PATH }} + working-directory: terraform run: | make destroy apigee_environment=$APIGEE_ENVIRONMENT environment=$BACKEND_ENVIRONMENT sub_environment=$BACKEND_SUB_ENVIRONMENT diff --git a/.github/workflows/run-e2e-tests.yml b/.github/workflows/run-e2e-tests.yml new file mode 100644 index 000000000..edd8ba2ff --- /dev/null +++ b/.github/workflows/run-e2e-tests.yml @@ -0,0 +1,228 @@ +name: Run e2e Tests + +on: + workflow_call: + inputs: + apigee_environment: + required: true + type: string + environment: + required: true + type: string + sub_environment: + required: true + type: string + workflow_dispatch: + inputs: + apigee_environment: + type: choice + description: Select the Apigee proxy environment + options: + - internal-dev + - int + - ref + - prod + environment: + type: string + description: Select the backend environment + options: + - dev + - preprod + - prod + sub_environment: + type: string + description: Set the sub environment name e.g. pr-xxx, or green/blue in higher environments + +env: + APIGEE_ENVIRONMENT: ${{ inputs.apigee_environment }} + ENVIRONMENT: ${{ inputs.environment }} + SUB_ENVIRONMENT: ${{ inputs.sub_environment }} + SERVICE_BASE_PATH: ${{ startsWith(inputs.sub_environment, 'pr-') && format('immunisation-fhir-api/FHIR/R4-{0}', inputs.sub_environment) || 'immunisation-fhir-api/FHIR/R4' }} + PROXY_NAME: ${{ startsWith(inputs.sub_environment, 'pr-') && format('immunisation-fhir-api-{0}', inputs.sub_environment) || format('immunisation-fhir-api-{0}', inputs.apigee_environment) }} + STATUS_API_KEY: ${{ secrets.STATUS_API_KEY }} + SOURCE_COMMIT_ID: ${{ github.sha }} + +permissions: + id-token: write + contents: read + +jobs: + wait-for-deployment: + runs-on: ubuntu-latest + environment: ${{ inputs.apigee_environment }} + outputs: + # Workaround for environment-level variables being unavailable in `jobs..if`. + RUN_BATCH_E2E_TESTS: ${{ vars.RUN_BATCH_E2E_TESTS }} + steps: + - name: Wait for API to be available + run: | + endpoint="" + if [[ ${APIGEE_ENVIRONMENT} =~ "prod" ]]; then + endpoint="https://api.service.nhs.uk/${SERVICE_BASE_PATH}/_status" + else + endpoint="https://${APIGEE_ENVIRONMENT}.api.service.nhs.uk/${SERVICE_BASE_PATH}/_status" + fi + + counter=0 + while [[ ${counter} -lt 31 ]]; do + response=$(curl -H "apikey: ${STATUS_API_KEY}" -s "${endpoint}") + + response_code=$(jq -r '.checks.healthcheck.responseCode' <<< "${response}") + response_body=$(jq -r '.checks.healthcheck.outcome' <<< "${response}") + status=$(jq -r '.status' <<< "${response}") + commitId=$(jq -r '.commitId' <<< "${response}") + + if [[ "${response_code}" -eq 200 ]] && [[ "${response_body}" == "OK" ]] && [[ "${status}" == "pass" ]]; then + echo "Status test successful" + if [[ "${commitId}" == "${SOURCE_COMMIT_ID}" ]]; then + echo "Commit hash test successful" + break + else + echo "Waiting for ${endpoint} to return the correct commit hash..." + fi + else + echo "Waiting for ${endpoint} to return a 200 response with 'OK' body..." + fi + + ((counter=counter+1)) # Increment counter by 1 + echo "Attempt ${counter}" + sleep 30 + done + + if [[ ${counter} -eq 31 ]]; then + echo "Status test failed: Maximum number of attempts reached" + echo "Last response received:" + echo "${response}" + exit 1 + fi + + e2e-tests: + runs-on: ubuntu-latest + needs: [wait-for-deployment] + environment: ${{ inputs.apigee_environment }} + env: + APIGEE_USERNAME: ${{ vars.APIGEE_USERNAME }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + + - name: Connect to AWS + if: ${{ vars.RUN_FULL_E2E_TESTS == 'true' }} + uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 + with: + aws-region: eu-west-2 + role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/auto-ops + role-session-name: github-actions + + - uses: hashicorp/setup-terraform@b9cd54a3c349d3f38e8881555d616ced269862dd + if: ${{ vars.RUN_FULL_E2E_TESTS == 'true' }} + with: + terraform_version: "1.12.2" + + - name: Terraform Init + if: ${{ vars.RUN_FULL_E2E_TESTS == 'true' }} + working-directory: terraform + run: make init + + - name: Set Terraform workspace + if: ${{ vars.RUN_FULL_E2E_TESTS == 'true' }} + working-directory: terraform + run: make workspace + + - name: Read Terraform outputs + if: ${{ vars.RUN_FULL_E2E_TESTS == 'true' }} + working-directory: terraform + run: | + echo "IMMS_DELTA_TABLE_NAME=$(make -s output name=imms_delta_table_name)" >> $GITHUB_ENV + echo "AWS_DOMAIN_NAME=$(make -s output name=service_domain_name)" >> $GITHUB_ENV + echo "DYNAMODB_TABLE_NAME=$(make -s output name=dynamodb_table_name)" >> $GITHUB_ENV + echo "AWS_SQS_QUEUE_NAME=$(make -s output name=aws_sqs_queue_name)" >> $GITHUB_ENV + echo "AWS_SNS_TOPIC_NAME=$(make -s output name=aws_sns_topic_name)" >> $GITHUB_ENV + + - name: Install poetry + run: pip install poetry==2.1.4 + + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c + with: + python-version: 3.11 + cache: "poetry" + cache-dependency-path: "e2e/poetry.lock" + + - name: Install e2e test dependencies + working-directory: e2e + run: poetry install --no-root + + - name: Get Apigee access token + if: ${{ vars.RUN_FULL_E2E_TESTS == 'true' }} + working-directory: e2e + env: + APIGEE_PASSWORD: ${{ secrets.APIGEE_PASSWORD }} + APIGEE_BASIC_AUTH_TOKEN: ${{ secrets.APIGEE_BASIC_AUTH_TOKEN }} + APIGEE_OTP_KEY: ${{ secrets.APIGEE_OTP_KEY }} + run: | + CODE=$(poetry run python utils/compute_totp_code.py "$APIGEE_OTP_KEY") + echo "::add-mask::$CODE" + + echo "Requesting access token from Apigee..." + response=$(curl -s -X POST "https://login.apigee.com/oauth/token" \ + -H "Content-Type: application/x-www-form-urlencoded" \ + -H "Accept: application/json;charset=utf-8" \ + -H "Authorization: Basic $APIGEE_BASIC_AUTH_TOKEN" \ + -d "username=$APIGEE_USERNAME&password=$APIGEE_PASSWORD&mfa_token=$CODE&grant_type=password") + + token=$(jq -e -r '.access_token' <<< "$response") + echo "::add-mask::$token" + echo "APIGEE_ACCESS_TOKEN=$token" >> $GITHUB_ENV + + - name: Run proxy deployment e2e test suite + working-directory: e2e + run: poetry run python -m unittest test_deployment + + - name: Run proxy e2e test suite + if: ${{ vars.RUN_PROXY_E2E_TESTS == 'true' }} + working-directory: e2e + run: poetry run python -m unittest test_proxy + + - name: Run sandbox e2e test suite + if: ${{ vars.RUN_SANDBOX_E2E_TESTS == 'true' }} + working-directory: e2e + run: poetry run python -m unittest test_proxy.TestProxyHealthcheck + + - name: Run full e2e test suite + if: ${{ vars.RUN_FULL_E2E_TESTS == 'true' }} + working-directory: e2e + run: poetry run python -m unittest + + batch-e2e-tests: + needs: [wait-for-deployment, e2e-tests] + # Only actually depend on wait-for-deployment, but run after e2e-tests + if: ${{ !cancelled() && needs.wait-for-deployment.result == 'success' && needs.wait-for-deployment.outputs.RUN_BATCH_E2E_TESTS == 'true' }} + runs-on: ubuntu-latest + environment: ${{ inputs.apigee_environment }} + steps: + - uses: actions/checkout@08c6903cd8c0fde910a37f88322edcfb5dd907a8 + + - name: Connect to AWS + uses: aws-actions/configure-aws-credentials@a03048d87541d1d9fcf2ecf528a4a65ba9bd7838 + with: + aws-region: eu-west-2 + role-to-assume: arn:aws:iam::${{ vars.AWS_ACCOUNT_ID }}:role/auto-ops + role-session-name: github-actions + + - name: Install poetry + run: pip install poetry==2.1.4 + + - uses: actions/setup-python@e797f83bcb11b83ae66e0230d6156d7c80228e7c + with: + python-version: 3.11 + cache: "poetry" + cache-dependency-path: "e2e_batch/poetry.lock" + + - name: Install e2e test dependencies + working-directory: e2e_batch + run: poetry install --no-root + + - name: Run batch e2e test suite + working-directory: e2e_batch + env: + ENVIRONMENT: ${{ inputs.sub_environment }} + run: poetry run python -m unittest -c -v diff --git a/e2e/poetry.lock b/e2e/poetry.lock index 14541eead..26ba906c6 100644 --- a/e2e/poetry.lock +++ b/e2e/poetry.lock @@ -1,4 +1,4 @@ -# This file is automatically @generated by Poetry 2.2.0 and should not be changed by hand. +# This file is automatically @generated by Poetry 2.1.4 and should not be changed by hand. [[package]] name = "boto3" @@ -441,6 +441,18 @@ files = [ [package.dependencies] typing-extensions = {version = "*", markers = "python_version < \"3.12\""} +[[package]] +name = "oath" +version = "1.4.4" +description = "Python implementation of the three main OATH specifications: HOTP, TOTP and OCRA" +optional = false +python-versions = "*" +groups = ["dev"] +files = [ + {file = "oath-1.4.4-py3-none-any.whl", hash = "sha256:503092f388f041f91737f6b3bd5b83e8cf3f40c7d9bc87bcfbfac33e0ae6d685"}, + {file = "oath-1.4.4.tar.gz", hash = "sha256:bd6b20d20f2c4e3f53523ee900211dca75aeeca72f4f5a9fd8dcacc175fe1c0b"}, +] + [[package]] name = "pycparser" version = "2.22" @@ -692,4 +704,4 @@ zstd = ["zstandard (>=0.18.0)"] [metadata] lock-version = "2.1" python-versions = "~3.11" -content-hash = "0d1be173bfbb153ae83a671d561ff8d8d84d459171a0644f0ccb9d93ed0b1005" +content-hash = "ed1e155a448d3908e4b17b9a7b26c2162e903d709cb0ba6ee94ddbfe28a6fe3c" diff --git a/e2e/pyproject.toml b/e2e/pyproject.toml index ae94d1de3..8240b05c0 100644 --- a/e2e/pyproject.toml +++ b/e2e/pyproject.toml @@ -17,6 +17,7 @@ requests = "^2.32.5" pyjwt = "^2.10.1" cryptography = "^42.0.3" lxml = "~4.9.0" +oath = "^1.4.4" [build-system] requires = ["poetry-core"] diff --git a/e2e/utils/compute_totp_code.py b/e2e/utils/compute_totp_code.py new file mode 100644 index 000000000..437d11707 --- /dev/null +++ b/e2e/utils/compute_totp_code.py @@ -0,0 +1,13 @@ +import base64 +import sys + +import oath + + +def compute_totp_code(key_base32: str) -> str: + key_hex = base64.b32decode(key_base32).hex() + return oath.totp(key_hex) + + +if __name__ == "__main__": + print(compute_totp_code(sys.argv[1])) diff --git a/infra/Makefile b/infra/Makefile index cb2889915..fd539f57e 100644 --- a/infra/Makefile +++ b/infra/Makefile @@ -14,7 +14,7 @@ lock-provider: $(tf_cmd) providers lock -platform=darwin_arm64 -platform=darwin_amd64 -platform=linux_amd64 -platform=windows_amd64 workspace: - $(tf_cmd) workspace new $(ENVIRONMENT) || $(tf_cmd) workspace select $(ENVIRONMENT) && echo "Switched to workspace/environment: $(ENVIRONMENT)" + $(tf_cmd) workspace select -or-create $(ENVIRONMENT) && echo "Switched to workspace/environment: $(ENVIRONMENT)" init: $(tf_cmd) init $(tf_state) -upgrade $(tf_vars) diff --git a/mesh_infra/Makefile b/mesh_infra/Makefile index 227e4c902..fd539f57e 100644 --- a/mesh_infra/Makefile +++ b/mesh_infra/Makefile @@ -14,7 +14,7 @@ lock-provider: $(tf_cmd) providers lock -platform=darwin_arm64 -platform=darwin_amd64 -platform=linux_amd64 -platform=windows_amd64 workspace: - $(tf_cmd) workspace new $(ENVIRONMENT) || $(tf_cmd) workspace select $(ENVIRONMENT) && echo "Switched to workspace/environment: $(ENVIRONMENT)" + $(tf_cmd) workspace select -or-create $(ENVIRONMENT) && echo "Switched to workspace/environment: $(ENVIRONMENT)" init: $(tf_cmd) init $(tf_state) -upgrade $(tf_vars) @@ -42,7 +42,7 @@ ifndef name endif $(tf_cmd) output -raw $(name) -import: +import: $(tf_cmd) import $(tf_vars) $(to) $(id) tf-%: diff --git a/terraform/Makefile b/terraform/Makefile index 4483f9f9d..46906d4a5 100644 --- a/terraform/Makefile +++ b/terraform/Makefile @@ -21,7 +21,7 @@ lock-provider: $(tf_cmd) providers lock -platform=darwin_arm64 -platform=darwin_amd64 -platform=linux_amd64 -platform=windows_amd64 workspace: - $(tf_cmd) workspace new $(sub_environment) || $(tf_cmd) workspace select $(sub_environment) && echo "Switched to workspace/environment: $(sub_environment)" + $(tf_cmd) workspace select -or-create $(sub_environment) && echo "Switched to workspace/environment: $(sub_environment)" init: $(tf_cmd) init $(tf_state) -upgrade $(tf_vars) diff --git a/terraform_aws_backup/aws-backup-destination/Makefile b/terraform_aws_backup/aws-backup-destination/Makefile index 1c0716087..a0adcc28c 100644 --- a/terraform_aws_backup/aws-backup-destination/Makefile +++ b/terraform_aws_backup/aws-backup-destination/Makefile @@ -20,13 +20,13 @@ lock-provider: $(tf_cmd) providers lock -platform=darwin_arm64 -platform=darwin_amd64 -platform=linux_amd64 -platform=windows_amd64 workspace: - $(tf_cmd) workspace new $(environment) || $(tf_cmd) workspace select $(environment) && echo "Switched to workspace/environment: $(environment)" + $(tf_cmd) workspace select -or-create $(environment) && echo "Switched to workspace/environment: $(environment)" init: - $(tf_cmd) init $(tf_state) -upgrade + $(tf_cmd) init $(tf_state) -upgrade plan: workspace - $(tf_cmd) plan + $(tf_cmd) plan plan-changes: workspace $(tf_cmd) plan -out=plan && $(tf_cmd) show -no-color -json plan | jq -r '.resource_changes[] | select(.change.actions[0]=="update" or .change.actions[0]=="create" or .change.actions[0]=="add") | .address' diff --git a/terraform_aws_backup/aws-backup-source/Makefile b/terraform_aws_backup/aws-backup-source/Makefile index 15bcc3235..90568762f 100644 --- a/terraform_aws_backup/aws-backup-source/Makefile +++ b/terraform_aws_backup/aws-backup-source/Makefile @@ -20,13 +20,13 @@ lock-provider: $(tf_cmd) providers lock -platform=darwin_arm64 -platform=darwin_amd64 -platform=linux_amd64 -platform=windows_amd64 workspace: - $(tf_cmd) workspace new $(environment) || $(tf_cmd) workspace select $(environment) && echo "Switched to workspace/environment: $(environment)" + $(tf_cmd) workspace select -or-create $(environment) && echo "Switched to workspace/environment: $(environment)" init: - $(tf_cmd) init $(tf_state) -upgrade + $(tf_cmd) init $(tf_state) -upgrade plan: workspace - $(tf_cmd) plan + $(tf_cmd) plan plan-changes: workspace $(tf_cmd) plan -out=plan && $(tf_cmd) show -no-color -json plan | jq -r '.resource_changes[] | select(.change.actions[0]=="update" or .change.actions[0]=="create" or .change.actions[0]=="add") | .address'