Skip to content

CDAPI-77 Mock API Gateway #239

CDAPI-77 Mock API Gateway

CDAPI-77 Mock API Gateway #239

Workflow file for this run

name: Preview Environment
on:
pull_request:
types:
- opened
- synchronize
- reopened
- closed
permissions:
id-token: write
contents: read
pull-requests: write
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 }}
PROXYGEN_API_NAME: ${{ vars.PROXYGEN_API_NAME }}
jobs:
pr-preview:
name: "PR preview management"
runs-on: ubuntu-latest
outputs:
function_name: ${{ steps.names.outputs.function_name }}
preview_url: ${{ steps.names.outputs.preview_url }}
steps:
- name: Checkout
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd
- name: Set up Python
uses: actions/setup-python@83679a892e2d95755f2dac6acb0bfd1e9ac5d548
with:
python-version: "${{ env.PYTHON_VERSION }}"
- name: "Setup Python project"
uses: ./.github/actions/setup-python-project
with:
python-version: ${{ env.PYTHON_VERSION }}
- name: Package artifact
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:
DEPENDABOT_AWS_ROLE_ARN: ${{ secrets.DEPENDABOT_AWS_ROLE_ARN }}
DEPENDABOT_LAMBDA_ROLE_ARN: ${{ secrets.DEPENDABOT_LAMBDA_ROLE_ARN }}
AWS_ROLE_ARN: ${{ secrets.AWS_ROLE_ARN }}
LAMBDA_ROLE_ARN: ${{ secrets.LAMBDA_ROLE_ARN }}
run: |
if [ "${{ github.actor }}" = "dependabot[bot]" ]; then
echo "aws_role=$DEPENDABOT_AWS_ROLE_ARN" >> "$GITHUB_OUTPUT"
echo "lambda_role=$DEPENDABOT_LAMBDA_ROLE_ARN" >> "$GITHUB_OUTPUT"
else
echo "aws_role=$AWS_ROLE_ARN" >> "$GITHUB_OUTPUT"
echo "lambda_role=$LAMBDA_ROLE_ARN" >> "$GITHUB_OUTPUT"
fi
- name: Configure AWS credentials (OIDC)
uses: aws-actions/configure-aws-credentials@a7a2c1125c67f40a1e95768f4e4a7d8f019f87af
with:
role-to-assume: ${{ steps.role-select.outputs.aws_role }}
aws-region: ${{ env.AWS_REGION }}
- name: Sanitize branch name
id: branch
env:
RAW_BRANCH_NAME: "${{ github.head_ref }}"
run: |
branch=$RAW_BRANCH_NAME
if [ -z "$branch" ]; then branch="${{ github.ref_name }}"; fi
safe=$(echo "$branch" | sed -E 's/[^a-zA-Z0-9._-]+/-/g' | tr '[:upper:]' '[:lower:]')
echo "branch=$branch" >> $GITHUB_OUTPUT
echo "safe=$safe" >> $GITHUB_OUTPUT
- name: Compute function name
id: names
run: |
SAFE=${{ steps.branch.outputs.safe }}
PREFIX=${{ env.PREVIEW_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 }}
JWKS_SECRET: ${{ secrets.JWKS_SECRET }}
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:-30s}"
JWKS_SECRET="${JWKS_SECRET_NAME:-/cds/pathology/dev/jwks/secret}"
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")
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 "$FN" >/dev/null 2>&1; then
wait_for_lambda_ready
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, \
JWKS_SECRET_NAME=$JWKS_SECRET}" || true
wait_for_lambda_ready
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, \
JWKS_SECRET_NAME=$JWKS_SECRET}" \
--publish
wait_for_lambda_ready
fi
- name: Delete preview Lambda (on PR closed)
if: github.event.action == 'closed'
run: |
FN="${{ steps.names.outputs.function_name }}"
echo "Deleting preview function: $FN"
aws lambda delete-function --function-name "$FN" || true
- name: Output function name
run: |
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'
env:
TOKEN_EXPIRY_TIME: ${{ secrets.TOKEN_LIFETIME }}
run: |
cd infrastructure/environments/preview
MFN="${{ steps.names.outputs.mock_function_name }}"
SAFE="${{ steps.branch.outputs.safe }}"
TOKEN_LIFETIME="${TOKEN_EXPIRY_TIME:-15m}"
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, \
TOKEN_LIFETIME=$TOKEN_LIFETIME}" || 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, \
TOKEN_LIFETIME=$TOKEN_LIFETIME}" \
--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'
id: mtls-certs
uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802
with:
secret-ids: |
/cds/pathology/dev/mtls/client1-key-secret
/cds/pathology/dev/mtls/client1-key-public
name-transformation: lowercase
- name: Smoke test preview URL
if: github.event.action != 'closed'
id: smoke-test
env:
PREVIEW_URL: ${{ steps.names.outputs.preview_url }}
run: |
if [ -z "$PREVIEW_URL" ] || [ "$PREVIEW_URL" = "null" ]; then
echo "Preview 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 "Preview 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 "Preview responded with status $STATUS"
echo "http_status=$STATUS" >> "$GITHUB_OUTPUT"
echo "http_result=success" >> "$GITHUB_OUTPUT"
exit 0
fi
echo "Preview 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: 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
with:
secret-ids: /cds/pathology/dev/proxygen/proxygen-key-secret
name-transformation: lowercase
- name: Deploy preview API proxy
if: github.event.action != 'closed'
uses: ./.github/actions/proxy/deploy-proxy
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 }}"
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 }}
proxygen-api-name: ${{ env.PROXYGEN_API_NAME }}
- name: Tear down preview API proxy
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 }}"
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 }}
proxygen-api-name: ${{ env.PROXYGEN_API_NAME }}
- name: Comment function name on PR
if: github.event_name == 'pull_request' && github.event.action != 'closed'
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd
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',
'allowed-404': ':white_check_mark: Allowed 404',
'unexpected-status': ':x: Unexpected status',
'missing-url': ':x: Missing URL',
};
const smokeReadable = smokeLabels[smokeResult] ?? smokeResult;
const smokeMockReadable = smokeLabels[smokeMockResult] ?? smokeMockResult;
const { data: comments } = await github.rest.issues.listComments({
owner,
repo,
issue_number: issueNumber,
per_page: 100,
});
for (const comment of comments) {
const isBot = comment.user?.login === 'github-actions[bot]';
const isPreviewUpdate = comment.body?.includes('Deployment Complete');
if (isBot && isPreviewUpdate) {
await github.rest.issues.deleteComment({
owner,
repo,
comment_id: comment.id,
});
}
}
const lines = [
'**Deployment Complete**',
`- 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({
owner,
repo,
issue_number: issueNumber,
body: lines.join('\n'),
});
# ---------- Perform trivy scan and notify ----------
- name: Prepare lambda artifact for trivy scan
if: github.event.action != 'closed'
run: |
cd pathology-api/target/
rm -rf /tmp/artifact
mkdir -p /tmp/artifact
unzip -q artifact.zip -d /tmp/artifact
- name: Trivy filesystem scan
if: github.event.action != 'closed'
uses: ./.github/actions/trivy-fs-scan
with:
filesystem-ref: /tmp/artifact
artifact-name: trivy-fs-scan-${{ steps.branch.outputs.safe }}
- name: Trivy SBOM generation
if: github.event.action != 'closed'
uses: ./.github/actions/trivy-fs-sbom
with:
fs-path: /tmp/artifact