diff --git a/.github/actions/lint-terraform/action.yaml b/.github/actions/lint-terraform/action.yaml deleted file mode 100644 index d5dfe35..0000000 --- a/.github/actions/lint-terraform/action.yaml +++ /dev/null @@ -1,20 +0,0 @@ -name: "Lint Terraform" -description: "Lint Terraform" -inputs: - root-modules: - description: "Comma separated list of root module directories to validate, content of the 'infrastructure/environments' is checked by default" - required: false -runs: - using: "composite" - steps: - - name: "Check Terraform format" - shell: bash - run: | - check_only=true scripts/githooks/check-terraform-format.sh - - name: "Validate Terraform" - shell: bash - run: | - stacks=${{ inputs.root-modules }} - for dir in $(find infrastructure/environments -maxdepth 1 -mindepth 1 -type d; echo ${stacks//,/$'\n'}); do - dir=$dir make terraform-validate - done diff --git a/.github/workflows/preview-env.yaml b/.github/workflows/preview-env.yaml index 7ec65e3..ec06cbf 100644 --- a/.github/workflows/preview-env.yaml +++ b/.github/workflows/preview-env.yaml @@ -16,9 +16,11 @@ permissions: env: AWS_REGION: eu-west-2 PREVIEW_PREFIX: pr- + MOCK_PREFIX: mock- PYTHON_VERSION: 3.14 LAMBDA_RUNTIME: python3.14 LAMBDA_HANDLER: lambda_handler.handler + MOCK_LAMBDA_HANDLER: handler.handler MTLS_SECRET_NAME: ${{ vars.PREVIEW_ENV_MTLS_SECRET_NAME }} PROXYGEN_KEY_ID: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }} PROXYGEN_CLIENT_ID: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }} @@ -50,6 +52,14 @@ jobs: run: | make build + # Place holder mock artifact packaging to allow testing of mock API in preview environment; + # can be extended to build a real mock Lambda if needed + - name: Package mock artifact + run: | + cd infrastructure/environments/preview + rm -f mock_artifact.zip + zip -r mock_artifact.zip . + - name: Select AWS role inputs id: role-select env: @@ -88,25 +98,49 @@ jobs: run: | SAFE=${{ steps.branch.outputs.safe }} PREFIX=${{ env.PREVIEW_PREFIX }} - MAX_FN_LEN=64 - MAX_SAFE_LEN=$((MAX_FN_LEN - ${#PREFIX})) + MOCK_PREFIX=${{ env.MOCK_PREFIX }} + MAX_FN_LEN=62 + MAX_PREFIX_LEN=${#PREFIX} + if [ ${#MOCK_PREFIX} -gt "$MAX_PREFIX_LEN" ]; then + MAX_PREFIX_LEN=${#MOCK_PREFIX} + fi + MAX_SAFE_LEN=$((MAX_FN_LEN - MAX_PREFIX_LEN)) if [ ${#SAFE} -gt "$MAX_SAFE_LEN" ]; then SAFE=${SAFE:0:MAX_SAFE_LEN} fi FN="${PREFIX}${SAFE}" + MFN="${MOCK_PREFIX}${SAFE}" echo "function_name=$FN" >> "$GITHUB_OUTPUT" + echo "mock_function_name=$MFN" >> "$GITHUB_OUTPUT" URL="https://${SAFE}.dev.endpoints.${{ env.PROXYGEN_API_NAME }}.national.nhs.uk" + MOCK_URL="https://${SAFE}.m.dev.endpoints.${{ env.PROXYGEN_API_NAME }}.national.nhs.uk" echo "preview_url=$URL" >> "$GITHUB_OUTPUT" + echo "mock_preview_url=$MOCK_URL" >> "$GITHUB_OUTPUT" + # ---------- Handle application ---------- - name: Create or update preview Lambda (on open/sync/reopen) if: github.event.action != 'closed' + env: + MOCK_URL: ${{ steps.names.outputs.mock_preview_url }} + EXPIRY_THRESHOLD: ${{ secrets.APIM_TOKEN_EXPIRY_THRESHOLD }} + APIM_PRIVATE_KEY: ${{ secrets.APIM_PRIVATE_KEY }} + APIM_APIKEY: ${{ secrets.APIM_APIKEY }} + API_MTLS_CERT: ${{ secrets.API_MTLS_CERT }} + API_MTLS_KEY: ${{ secrets.API_MTLS_KEY }} run: | cd pathology-api/target/ FN="${{ steps.names.outputs.function_name }}" + EXPIRY_THRESHOLD="${TOKEN_EXPIRY_THRESHOLD:-840s}" + PRIVATE_KEY="${APIM_PRIVATE_KEY:-/cds/pathology/dev/apim/private-key}" + API_KEY="${APIM_APIKEY:-/cds/pathology/dev/apim/api-key}" + MTLS_CERT="${API_MTLS_CERT:-/cds/pathology/dev/mtls/client1-key-public}" + MTLS_KEY="${API_MTLS_KEY:-/cds/pathology/dev/mtls/client1-key-secret}" echo "Deploying preview function: $FN" wait_for_lambda_ready() { while true; do - status=$(aws lambda get-function-configuration --function-name "$FN" --query 'LastUpdateStatus' --output text 2>/dev/null || echo "Unknown") + status=$(aws lambda get-function-configuration --function-name "$FN" \ + --query 'LastUpdateStatus' \ + --output text 2>/dev/null || echo "Unknown") if [ "$status" = "Successful" ] || [ "$status" = "Unknown" ]; then break fi @@ -120,15 +154,34 @@ jobs: } if aws lambda get-function --function-name "$FN" >/dev/null 2>&1; then wait_for_lambda_ready - aws lambda update-function-configuration --function-name "$FN" --handler "${{ env.LAMBDA_HANDLER }}" || true + aws lambda update-function-configuration --function-name "$FN" \ + --handler "${{ env.LAMBDA_HANDLER }}" \ + --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ + APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \ + APIM_API_KEY_NAME=$API_KEY, \ + APIM_MTLS_CERT_NAME=$MTLS_CERT, \ + APIM_MTLS_KEY_NAME=$MTLS_KEY, \ + APIM_TOKEN_URL=$MOCK_URL/apim, \ + PDM_BUNDLE_URL=$MOCK_URL/pdm, \ + MNS_EVENT_URL=$MOCK_URL/mns}" || true wait_for_lambda_ready - aws lambda update-function-code --function-name "$FN" --zip-file "fileb://artifact.zip" --publish + aws lambda update-function-code --function-name "$FN" \ + --zip-file "fileb://artifact.zip" \ + --publish else aws lambda create-function --function-name "$FN" \ --runtime "${{ env.LAMBDA_RUNTIME }}" \ --handler "${{ env.LAMBDA_HANDLER }}" \ --zip-file "fileb://artifact.zip" \ --role "${{ steps.role-select.outputs.lambda_role }}" \ + --environment "Variables={APIM_TOKEN_EXPIRY_THRESHOLD=$EXPIRY_THRESHOLD, \ + APIM_PRIVATE_KEY_NAME=$PRIVATE_KEY, \ + APIM_API_KEY_NAME=$API_KEY, \ + APIM_MTLS_CERT_NAME=$MTLS_CERT, \ + APIM_MTLS_KEY_NAME=$MTLS_KEY, \ + APIM_TOKEN_URL=$MOCK_URL/apim, \ + PDM_BUNDLE_URL=$MOCK_URL/pdm, \ + MNS_EVENT_URL=$MOCK_URL/mns}" \ --publish wait_for_lambda_ready fi @@ -145,6 +198,60 @@ jobs: echo "function = ${{ steps.names.outputs.function_name }}" echo "url = ${{ steps.names.outputs.preview_url }}" + # ---------- Handle mock endpoints ---------- + - name: Create or update mock Lambda (on open/sync/reopen) + if: github.event.action != 'closed' + run: | + cd infrastructure/environments/preview + MFN="${{ steps.names.outputs.mock_function_name }}" + SAFE="${{ steps.branch.outputs.safe }}" + echo "Deploying mock function: $MFN" + wait_for_lambda_ready() { + while true; do + status=$(aws lambda get-function-configuration --function-name "$MFN" --query 'LastUpdateStatus' --output text 2>/dev/null || echo "Unknown") + if [ "$status" = "Successful" ] || [ "$status" = "Unknown" ]; then + break + fi + if [ "$status" = "Failed" ]; then + echo "Lambda is in Failed state; check logs." >&2 + exit 1 + fi + echo "Lambda update status: $status — waiting..." + sleep 5 + done + } + if aws lambda get-function --function-name "$MFN" >/dev/null 2>&1; then + wait_for_lambda_ready + aws lambda update-function-configuration --function-name "$MFN" \ + --handler "${{ env.MOCK_LAMBDA_HANDLER }}" \ + --environment "Variables={CLIENT_PUBLIC_KEY_ARN=mock, \ + DDB_INDEX_TAG=$SAFE}" || true + wait_for_lambda_ready + aws lambda update-function-code --function-name "$MFN" --zip-file "fileb://mock_artifact.zip" --publish + else + aws lambda create-function --function-name "$MFN" \ + --runtime "${{ env.LAMBDA_RUNTIME }}" \ + --handler "${{ env.MOCK_LAMBDA_HANDLER }}" \ + --zip-file "fileb://mock_artifact.zip" \ + --role "${{ steps.role-select.outputs.lambda_role }}" \ + --environment "Variables={CLIENT_PUBLIC_KEY_ARN=mock, \ + DDB_INDEX_TAG=$SAFE}" \ + --publish + wait_for_lambda_ready + fi + + - name: Delete mock Lambda (on PR closed) + if: github.event.action == 'closed' + run: | + MFN="${{ steps.names.outputs.mock_function_name }}" + echo "Deleting mock function: $MFN" + aws lambda delete-function --function-name "$MFN" || true + + - name: Output mock function name + run: | + echo "mock_function = ${{ steps.names.outputs.mock_function_name }}" + echo "mock_url = ${{ steps.names.outputs.mock_preview_url }}" + # ---------- Wait on AWS tasks and notify ---------- - name: Get mTLS certs for testing if: github.event.action != 'closed' @@ -207,6 +314,57 @@ jobs: echo "http_result=unexpected-status" >> "$GITHUB_OUTPUT" exit 0 + - name: Smoke test mock URL + if: github.event.action != 'closed' + id: smoke-mock + env: + PREVIEW_URL: ${{ steps.names.outputs.mock_preview_url }} + run: | + if [ -z "$PREVIEW_URL" ] || [ "$PREVIEW_URL" = "null" ]; then + echo "Mock URL missing" + echo "http_status=missing" >> "$GITHUB_OUTPUT" + echo "http_result=missing-url" >> "$GITHUB_OUTPUT" + exit 0 + fi + + # Reachability check: allow 404 (app routes might not exist yet) but fail otherwise + printf '%s' "$_cds_pathology_dev_mtls_client1_key_secret" > /tmp/client1-key.pem + printf '%s' "$_cds_pathology_dev_mtls_client1_key_public" > /tmp/client1-cert.pem + STATUS=$(curl \ + --cert /tmp/client1-cert.pem \ + --key /tmp/client1-key.pem \ + --silent \ + --output /tmp/preview.headers \ + --write-out '%{http_code}' \ + --head \ + --max-time 30 \ + -X GET "$PREVIEW_URL"/_status || true) + rm -f /tmp/client1-key.pem + rm -f /tmp/client1-cert.pem + + if [ "$STATUS" = "404" ]; then + echo "Mock responded with expected 404" + echo "http_status=404" >> "$GITHUB_OUTPUT" + echo "http_result=allowed-404" >> "$GITHUB_OUTPUT" + exit 0 + fi + + if [[ "$STATUS" =~ ^[0-9]{3}$ ]] && [ "$STATUS" -ge 200 ] && [ "$STATUS" -lt 400 ]; then + echo "Mock responded with status $STATUS" + echo "http_status=$STATUS" >> "$GITHUB_OUTPUT" + echo "http_result=success" >> "$GITHUB_OUTPUT" + exit 0 + fi + + echo "Mock responded with unexpected status $STATUS" + if [ -f /tmp/preview.headers ]; then + echo "Response headers:" + cat /tmp/preview.headers + fi + echo "http_status=$STATUS" >> "$GITHUB_OUTPUT" + echo "http_result=unexpected-status" >> "$GITHUB_OUTPUT" + exit 0 + - name: Get proxygen machine user details id: proxygen-machine-user uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 @@ -220,7 +378,7 @@ jobs: with: mtls-secret-name: ${{ env.MTLS_SECRET_NAME }} target-url: ${{ steps.names.outputs.preview_url }} - proxy-base-path: '${{ env.PROXYGEN_API_NAME }}-pr-${{ github.event.pull_request.number }}' + proxy-base-path: "${{ env.PROXYGEN_API_NAME }}-pr-${{ github.event.pull_request.number }}" proxygen-key-secret: ${{ env._cds_pathology_dev_proxygen_proxygen_key_secret }} proxygen-key-id: ${{ env.PROXYGEN_KEY_ID }} proxygen-client-id: ${{ env.PROXYGEN_CLIENT_ID }} @@ -230,7 +388,7 @@ jobs: if: github.event.action == 'closed' uses: ./.github/actions/proxy/tear-down-proxy with: - proxy-base-path: '${{ env.PROXYGEN_API_NAME }}-pr-${{ github.event.pull_request.number }}' + proxy-base-path: "${{ env.PROXYGEN_API_NAME }}-pr-${{ github.event.pull_request.number }}" proxygen-key-secret: ${{ env._cds_pathology_dev_proxygen_proxygen_key_secret }} proxygen-key-id: ${{ env.PROXYGEN_KEY_ID }} proxygen-client-id: ${{ env.PROXYGEN_CLIENT_ID }} @@ -242,13 +400,17 @@ jobs: with: script: | const fn = '${{ steps.names.outputs.function_name }}'; + const mock_fn = '${{ steps.names.outputs.mock_function_name }}'; const url = '${{ steps.names.outputs.preview_url }}'; + const mock_url = '${{ steps.names.outputs.mock_preview_url }}'; const proxy_url = 'https://internal-dev.api.service.nhs.uk/${{ env.PROXYGEN_API_NAME }}-pr-${{ github.event.pull_request.number }}'; const owner = context.repo.owner; const repo = context.repo.repo; const issueNumber = context.issue.number; const smokeStatus = '${{ steps.smoke-test.outputs.http_status }}' || 'n/a'; const smokeResult = '${{ steps.smoke-test.outputs.http_result }}' || 'not-run'; + const smokeMockStatus = '${{ steps.smoke-mock.outputs.http_status }}' || 'n/a'; + const smokeMockResult = '${{ steps.smoke-mock.outputs.http_result }}' || 'not-run'; const smokeLabels = { success: ':white_check_mark: Passed', @@ -258,7 +420,7 @@ jobs: }; const smokeReadable = smokeLabels[smokeResult] ?? smokeResult; - + const smokeMockReadable = smokeLabels[smokeMockResult] ?? smokeMockResult; const { data: comments } = await github.rest.issues.listComments({ owner, repo, @@ -281,10 +443,13 @@ jobs: const lines = [ '**Deployment Complete**', - `- Preview URL: [${url}](${url}) — [Status endpoint](${url}/_status)`, - `- Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`, + `- Preview URL: [${url}](${url}) — [Status](${url}/_status)`, + ` - Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`, + `- Mock URL: [${mock_url}](${mock_url})`, + ` - Smoke Mock Test: ${smokeMockReadable} (HTTP ${smokeMockStatus})`, `- Proxy URL: [${proxy_url}](${proxy_url})`, `- Lambda Function: ${fn}`, + `- Mock Lambda Function: ${mock_fn}`, ]; await github.rest.issues.createComment({ diff --git a/.github/workflows/stage-1-commit.yaml b/.github/workflows/stage-1-commit.yaml index e1ba638..0088a63 100644 --- a/.github/workflows/stage-1-commit.yaml +++ b/.github/workflows/stage-1-commit.yaml @@ -88,15 +88,6 @@ jobs: fetch-depth: 0 # Full history is needed to compare branches - name: "Check English usage" uses: ./.github/actions/check-english-usage - lint-terraform: - name: "Lint Terraform" - runs-on: ubuntu-latest - timeout-minutes: 2 - steps: - - name: "Checkout code" - uses: actions/checkout@v6 - - name: "Lint Terraform" - uses: ./.github/actions/lint-terraform count-lines-of-code: name: "Count lines of code" runs-on: ubuntu-latest diff --git a/.tool-versions b/.tool-versions index ac5171c..052bb56 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,6 +1,5 @@ # This file is for you! Please, updated to the versions agreed by your team. -terraform 1.7.0 pre-commit 3.6.0 gitleaks 8.18.4 @@ -15,7 +14,6 @@ gitleaks 8.18.4 # docker/ghcr.io/make-ops-tools/gocloc latest@sha256:6888e62e9ae693c4ebcfed9f1d86c70fd083868acb8815fe44b561b9a73b5032 # SEE: https://github.com/make-ops-tools/gocloc/pkgs/container/gocloc # docker/ghcr.io/nhs-england-tools/github-runner-image 20230909-321fd1e-rt@sha256:ce4fd6035dc450a50d3cbafb4986d60e77cb49a71ab60a053bb1b9518139a646 # SEE: https://github.com/nhs-england-tools/github-runner-image/pkgs/container/github-runner-image # docker/hadolint/hadolint 2.12.0-alpine@sha256:7dba9a9f1a0350f6d021fb2f6f88900998a4fb0aaf8e4330aa8c38544f04db42 # SEE: https://hub.docker.com/r/hadolint/hadolint/tags -# docker/hashicorp/terraform 1.12.2@sha256:b3d13c9037d2bd858fe10060999aa7ca56d30daafe067d7715b29b3d4f5b162f # SEE: https://hub.docker.com/r/hashicorp/terraform/tags # docker/koalaman/shellcheck latest@sha256:e40388688bae0fcffdddb7e4dea49b900c18933b452add0930654b2dea3e7d5c # SEE: https://hub.docker.com/r/koalaman/shellcheck/tags # docker/mstruebing/editorconfig-checker 2.7.1@sha256:dd3ca9ea50ef4518efe9be018d669ef9cf937f6bb5cfe2ef84ff2a620b5ddc24 # SEE: https://hub.docker.com/r/mstruebing/editorconfig-checker/tags # docker/sonarsource/sonar-scanner-cli 10.0@sha256:0bc49076468d2955948867620b2d98d67f0d59c0fd4a5ef1f0afc55cf86f2079 # SEE: https://hub.docker.com/r/sonarsource/sonar-scanner-cli/tags diff --git a/infrastructure/environments/preview/handler.py b/infrastructure/environments/preview/handler.py new file mode 100644 index 0000000..5ca294e --- /dev/null +++ b/infrastructure/environments/preview/handler.py @@ -0,0 +1,40 @@ +import json +import logging +from typing import Any + +logger = logging.getLogger() +logger.setLevel(logging.INFO) + + +def handler(event: dict[str, Any], context: Any): + """Handler for the preview environment. This simply returns a 200 response with + the request headers, which can be used to verify and debug how the environment + is working and to inspect the incoming request. + + Args: + event (dict[str, Any]): Dictionary containing request data from API Gateway. + context (Any): AWS lambda context object (what the lambda is running in) + + Returns: + dict: Diagnostic 200 response with request headers. + """ + logger.info("Lambda context: %s", context) + headers = event.get("headers", {}) or {} + + # Log headers to CloudWatch + logger.info("Incoming request headers:") + for k, v in headers.items(): + logger.info("%s: %s", k, v) + + response_body = { + "message": "ok", + "headers": headers, + "requestContext": event.get("requestContext", {}), + } + + return { + "statusCode": 200, + "headers": {"content-type": "application/json"}, + "body": json.dumps(response_body, indent=2), + "isBase64Encoded": False, + } diff --git a/scripts/config/pre-commit.yaml b/scripts/config/pre-commit.yaml index e63b58d..0cc37cd 100644 --- a/scripts/config/pre-commit.yaml +++ b/scripts/config/pre-commit.yaml @@ -25,11 +25,6 @@ repos: args: ["-c", "check=staged-changes ./scripts/githooks/check-english-usage.sh"] language: system pass_filenames: false - - id: lint-terraform - name: Lint Terraform - entry: ./scripts/githooks/check-terraform-format.sh - language: script - pass_filenames: false - id: python-linting-and-formatting name: Python linting and formatting entry: ./scripts/githooks/python-lint-and-format.sh diff --git a/scripts/githooks/check-terraform-format.sh b/scripts/githooks/check-terraform-format.sh deleted file mode 100755 index 7255e51..0000000 --- a/scripts/githooks/check-terraform-format.sh +++ /dev/null @@ -1,56 +0,0 @@ -#!/bin/bash - -# WARNING: Please DO NOT edit this file! It is maintained in the Repository Template (https://github.com/nhs-england-tools/repository-template). Raise a PR instead. - -set -euo pipefail - -# Pre-commit git hook to check format Terraform code. -# -# Usage: -# $ [options] ./check-terraform-format.sh -# -# Options: -# check_only=true # Do not format, run check only, default is 'false' -# FORCE_USE_DOCKER=true # If set to true the command is run in a Docker container, default is 'false' -# VERBOSE=true # Show all the executed commands, default is 'false' - -# ============================================================================== - -function main() { - - cd "$(git rev-parse --show-toplevel)" - - local check_only=${check_only:-false} - check_only=$check_only terraform-fmt -} - -# Format Terraform files. -# Arguments (provided as environment variables): -# check_only=[do not format, run check only] -function terraform-fmt() { - - local opts= - if is-arg-true "$check_only"; then - opts="-check" - fi - opts=$opts make terraform-fmt -} - -# ============================================================================== - -function is-arg-true() { - - if [[ "$1" =~ ^(true|yes|y|on|1|TRUE|YES|Y|ON)$ ]]; then - return 0 - else - return 1 - fi -} - -# ============================================================================== - -is-arg-true "${VERBOSE:-false}" && set -x - -main "$@" - -exit 0 diff --git a/sonar-project.properties b/sonar-project.properties index def2564..897538e 100644 --- a/sonar-project.properties +++ b/sonar-project.properties @@ -13,6 +13,6 @@ sonar.exclusions=docs/**,pathology-api/test-artefacts/**,pathology-api/coverage- sonar.tests=pathology-api sonar.test.inclusions=pathology-api/**/test_*.py, pathology-api/tests/** -sonar.coverage.exclusions=**/tests/**,**/features/**,**/test_*.py,infrastructure/images/api-gateway-mock/resources/** +sonar.coverage.exclusions=**/tests/**,**/features/**,**/test_*.py,infrastructure/images/api-gateway-mock/resources/**,**/infrastructure/environments/preview/** # Set Python version for more precise analysis sonar.python.version=3.14