Add alpha integration environment workflow #792
Workflow file for this run
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| name: Preview Environment | |
| on: | |
| pull_request: | |
| types: [opened, reopened, synchronize, closed] | |
| env: | |
| AWS_REGION: eu-west-2 | |
| AWS_ACCOUNT_ID: "900119715266" | |
| ECR_REPOSITORY_NAME: "whoami" | |
| TF_STATE_BUCKET: "cds-cdg-dev-tfstate-900119715266" | |
| PREVIEW_STATE_PREFIX: "dev/preview/" | |
| BASE_URL: "https://internal-dev.api.service.nhs.uk/clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}" | |
| python_version: "3.14" | |
| PROXYGEN_API_NAME: ${{ vars.PROXYGEN_API_NAME }} | |
| PR_NUMBER: ${{ github.event.pull_request.number }} | |
| jobs: | |
| preview: | |
| name: Manage preview environment | |
| runs-on: ubuntu-latest | |
| # Needed for OIDC → AWS (recommended) | |
| permissions: | |
| id-token: write | |
| contents: read | |
| pull-requests: write | |
| # One job per branch at a time | |
| concurrency: | |
| group: preview-${{ github.head_ref || github.ref_name }} | |
| cancel-in-progress: true | |
| env: | |
| DEV_AWS_ROLE_ARN: ${{ secrets.DEV_AWS_CREDENTIALS }} | |
| steps: | |
| - name: Checkout repo | |
| uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 | |
| - name: Select AWS role inputs | |
| id: role-select | |
| env: | |
| DEPENDABOT_AWS_ROLE_ARN: ${{ secrets.DEPENDABOT_AWS_ROLE_ARN }} | |
| AWS_ROLE_ARN: ${{ secrets.DEV_AWS_CREDENTIALS }} | |
| run: | | |
| if [ "${{ github.actor }}" = "dependabot[bot]" ]; then | |
| echo "aws_role=$DEPENDABOT_AWS_ROLE_ARN" >> "$GITHUB_OUTPUT" | |
| else | |
| echo "aws_role=$AWS_ROLE_ARN" >> "$GITHUB_OUTPUT" | |
| fi | |
| # Configure AWS credentials (OIDC recommended) | |
| - name: Configure AWS credentials | |
| uses: aws-actions/configure-aws-credentials@b1257c400167d727708335212f95607835cd03fd | |
| with: | |
| role-to-assume: ${{ steps.role-select.outputs.aws_role }} | |
| aws-region: ${{ env.AWS_REGION }} | |
| - name: Login to Amazon ECR | |
| id: ecr-login | |
| uses: aws-actions/amazon-ecr-login@c962da2960ed15f492addc26fffa274485265950 | |
| - name: Compute branch metadata | |
| id: meta | |
| run: | | |
| # For PRs, head_ref is the source branch name | |
| RAW_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" | |
| # Dependabot PRs should use a stable preview identifier based on PR number, | |
| # not the branch name (which contains slashes and can be long). | |
| if [ "${GITHUB_ACTOR}" = "dependabot[bot]" ] || [[ "$RAW_BRANCH" == dependabot/* ]]; then | |
| SANITIZED_BRANCH="dependabot-${{ github.event.pull_request.number }}" | |
| else | |
| # Sanitize branch name for tags / hostnames (lowercase, only allowed chars) | |
| SANITIZED_BRANCH=$( | |
| printf '%s' "$RAW_BRANCH" \ | |
| | tr '[:upper:]' '[:lower:]' \ | |
| | tr '._' '-' \ | |
| | tr -c 'a-z0-9-' '-' \ | |
| | sed -E 's/-{2,}/-/g; s/^-+//; s/-+$//' | |
| ) | |
| # Last resort fallback if everything got stripped | |
| if [ -z "$SANITIZED_BRANCH" ]; then | |
| SANITIZED_BRANCH="invalid-branch-name" | |
| fi | |
| fi | |
| echo "raw_branch=$RAW_BRANCH" >> $GITHUB_OUTPUT | |
| echo "branch_name=$SANITIZED_BRANCH" >> $GITHUB_OUTPUT | |
| # ECR repo URL (must match core stack's ECR repo) | |
| ECR_URL="${AWS_ACCOUNT_ID}.dkr.ecr.${AWS_REGION}.amazonaws.com/${ECR_REPOSITORY_NAME}" | |
| echo "ecr_url=$ECR_URL" >> $GITHUB_OUTPUT | |
| # Terraform state key for this preview env | |
| TF_STATE_KEY="${PREVIEW_STATE_PREFIX}${SANITIZED_BRANCH}.tfstate" | |
| echo "tf_state_key=$TF_STATE_KEY" >> $GITHUB_OUTPUT | |
| # ALB listener rule priority - derive from PR number (must be unique per listener) | |
| if [ -n "${{ github.event.number }}" ]; then | |
| PRIORITY=$(( 1000 + ${{ github.event.number }} )) | |
| else | |
| PRIORITY=1999 | |
| fi | |
| echo "alb_rule_priority=$PRIORITY" >> $GITHUB_OUTPUT | |
| - name: Setup Python project | |
| if: github.event.action != 'closed' | |
| uses: ./.github/actions/setup-python-project | |
| with: | |
| python-version: ${{ env.python_version }} | |
| - name: Build Docker image | |
| if: github.event.action != 'closed' | |
| env: | |
| PYTHON_VERSION: ${{ env.python_version }} | |
| run: | | |
| IMAGE_TAG="${{ steps.meta.outputs.branch_name }}" | |
| ECR_URL="${{ steps.meta.outputs.ecr_url }}" | |
| make build IMAGE_TAG="${IMAGE_TAG}" ECR_URL="${ECR_URL}" | |
| - name: Push Docker image to ECR | |
| if: github.event.action != 'closed' | |
| run: | | |
| IMAGE_TAG="${{ steps.meta.outputs.branch_name }}" | |
| ECR_URL="${{ steps.meta.outputs.ecr_url }}" | |
| docker push "${ECR_URL}:${IMAGE_TAG}" | |
| - name: Setup Terraform | |
| uses: hashicorp/setup-terraform@5e8dbf3c6d9deaf4193ca7a8fb23f2ac83bb6c85 | |
| with: | |
| terraform_version: 1.14.0 | |
| # ---------- APPLY (PR opened / updated) ---------- | |
| - name: Terraform init (apply) | |
| if: github.event.action != 'closed' | |
| working-directory: infrastructure/environments/preview | |
| run: | | |
| terraform init \ | |
| -backend-config="bucket=${TF_STATE_BUCKET}" \ | |
| -backend-config="key=${{ steps.meta.outputs.tf_state_key }}" \ | |
| -backend-config="region=${AWS_REGION}" | |
| - name: Terraform apply preview env | |
| if: github.event.action != 'closed' | |
| working-directory: infrastructure/environments/preview | |
| env: | |
| TF_VAR_branch_name: ${{ steps.meta.outputs.branch_name }} | |
| TF_VAR_image_tag: ${{ steps.meta.outputs.branch_name }} | |
| TF_VAR_alb_rule_priority: ${{ steps.meta.outputs.alb_rule_priority }} | |
| run: | | |
| terraform apply \ | |
| -var-file="preview.tfvars" \ | |
| -auto-approve | |
| - name: Capture preview TF outputs | |
| if: github.event.action != 'closed' | |
| id: tf-output | |
| working-directory: infrastructure/environments/preview | |
| run: | | |
| terraform output -json > tf-output.json | |
| URL=$(jq -r '.url.value' tf-output.json) | |
| echo "preview_url=$URL" >> $GITHUB_OUTPUT | |
| TG=$(jq -r '.target_group_arn.value' tf-output.json) | |
| echo "target_group=$TG" >> $GITHUB_OUTPUT | |
| ECS_SERVICE=$(jq -r '.ecs_service_name.value' tf-output.json) | |
| echo "ecs_service=$ECS_SERVICE" >> $GITHUB_OUTPUT | |
| ECS_CLUSTER=$(jq -r '.ecs_cluster_name.value' tf-output.json) | |
| echo "ecs_cluster=$ECS_CLUSTER" >> $GITHUB_OUTPUT | |
| - name: Get proxygen machine user details | |
| id: proxygen-machine-user | |
| uses: aws-actions/aws-secretsmanager-get-secrets@a9a7eb4e2f2871d30dc5b892576fde60a2ecc802 | |
| with: | |
| secret-ids: /cds/gateway/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: ${{ vars.PREVIEW_ENV_MTLS_SECRET_NAME}} | |
| target-url: ${{ steps.tf-output.outputs.preview_url }} | |
| proxy-base-path: "clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}" | |
| proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }} | |
| proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }} | |
| proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }} | |
| proxygen-client-id: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }} | |
| - name: Tear down preview API proxy | |
| if: github.event.action == 'closed' | |
| uses: ./.github/actions/proxy/tear-down-proxy | |
| with: | |
| proxy-base-path: "clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}" | |
| proxygen-key-secret: ${{ env._cds_gateway_dev_proxygen_proxygen_key_secret }} | |
| proxygen-key-id: ${{ vars.PREVIEW_ENV_PROXYGEN_KEY_ID }} | |
| proxygen-api-name: ${{ vars.PROXYGEN_API_NAME }} | |
| proxygen-client-id: ${{ vars.PREVIEW_ENV_PROXYGEN_CLIENT_ID }} | |
| # ---------- Ensure re-deployment (PR updated) ---------- | |
| - name: Force ECS service redeployment | |
| if: github.event.action == 'synchronize' | |
| id: await-redeployment | |
| run: | | |
| aws ecs update-service \ | |
| --cluster ${{ steps.tf-output.outputs.ecs_cluster }} \ | |
| --service ${{ steps.tf-output.outputs.ecs_service }} \ | |
| --force-new-deployment \ | |
| --region ${{ env.AWS_REGION }} | |
| # ---------- DESTROY (PR closed) ---------- | |
| - name: Terraform init (destroy) | |
| if: github.event.action == 'closed' | |
| working-directory: infrastructure/environments/preview | |
| run: | | |
| terraform init \ | |
| -backend-config="bucket=${TF_STATE_BUCKET}" \ | |
| -backend-config="key=${{ steps.meta.outputs.tf_state_key }}" \ | |
| -backend-config="region=${AWS_REGION}" | |
| - name: Terraform destroy preview env | |
| if: github.event.action == 'closed' | |
| working-directory: infrastructure/environments/preview | |
| env: | |
| TF_VAR_branch_name: ${{ steps.meta.outputs.branch_name }} | |
| TF_VAR_image_tag: ${{ steps.meta.outputs.branch_name }} | |
| TF_VAR_alb_rule_priority: ${{ steps.meta.outputs.alb_rule_priority }} | |
| run: | | |
| terraform destroy \ | |
| -var-file="preview.tfvars" \ | |
| -auto-approve | |
| # ---------- Wait on AWS tasks and notify ---------- | |
| - name: Await deployment completion | |
| if: github.event.action != 'closed' | |
| run: | | |
| aws ecs wait services-stable \ | |
| --cluster ${{ steps.tf-output.outputs.ecs_cluster }} \ | |
| --services ${{ steps.tf-output.outputs.ecs_service }} \ | |
| --region ${{ env.AWS_REGION }} | |
| - 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/gateway/dev/mtls/client1-key-secret | |
| /cds/gateway/dev/mtls/client1-key-public | |
| name-transformation: lowercase | |
| # Prepare cert files for the following test suites | |
| - name: Prepare mTLS cert files for tests | |
| if: github.event.action != 'closed' | |
| run: | | |
| printf '%s' "$_cds_gateway_dev_mtls_client1_key_secret" > /tmp/client1-key.pem | |
| printf '%s' "$_cds_gateway_dev_mtls_client1_key_public" > /tmp/client1-cert.pem | |
| chmod 600 /tmp/client1-key.pem /tmp/client1-cert.pem | |
| - name: Smoke test preview URL | |
| if: github.event.action != 'closed' | |
| id: smoke-test | |
| env: | |
| PREVIEW_URL: ${{ steps.tf-output.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 | |
| 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 "$PREVIEW_URL"/health || true) | |
| 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 | |
| # ---------- QUALITY CHECKS (Test Suites) ---------- | |
| - name: Retrieve Apigee Token | |
| id: apigee-token | |
| shell: bash | |
| run: | | |
| set -euo pipefail | |
| APIGEE_TOKEN="$(proxygen pytest-nhsd-apim get-token | jq -r '.pytest_nhsd_apim_token' 2>/dev/null)" | |
| if [ -z "$APIGEE_TOKEN" ] || [ "$APIGEE_TOKEN" = "null" ]; then | |
| echo "::error::Failed to retrieve Apigee token" | |
| exit 1 | |
| fi | |
| echo "::add-mask::$APIGEE_TOKEN" | |
| printf 'apigee-access-token=%s\n' "$APIGEE_TOKEN" >> "$GITHUB_OUTPUT" | |
| echo "Token retrieved successfully (length: ${#APIGEE_TOKEN})" | |
| - name: "Run unit tests" | |
| if: github.event.action != 'closed' | |
| uses: ./.github/actions/run-test-suite | |
| with: | |
| test-type: unit | |
| env: local | |
| - name: "Run contract tests" | |
| if: github.event.action != 'closed' | |
| uses: ./.github/actions/run-test-suite | |
| with: | |
| test-type: contract | |
| apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }} | |
| base-url: ${{ env.BASE_URL }} | |
| - name: "Run schema validation tests" | |
| if: github.event.action != 'closed' | |
| uses: ./.github/actions/run-test-suite | |
| with: | |
| test-type: schema | |
| apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }} | |
| base-url: ${{ env.BASE_URL }} | |
| - name: "Run integration tests" | |
| if: github.event.action != 'closed' | |
| uses: ./.github/actions/run-test-suite | |
| with: | |
| test-type: integration | |
| apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }} | |
| base-url: ${{ env.BASE_URL }} | |
| - name: "Run acceptance tests" | |
| if: github.event.action != 'closed' | |
| uses: ./.github/actions/run-test-suite | |
| with: | |
| test-type: acceptance | |
| apigee-access-token: ${{ steps.apigee-token.outputs.apigee-access-token }} | |
| base-url: ${{ env.BASE_URL }} | |
| # Cleanup after tests | |
| - name: Remove mTLS temp files | |
| if: github.event.action != 'closed' | |
| run: rm -f /tmp/client1-key.pem /tmp/client1-cert.pem | |
| - name: Comment function name on PR | |
| if: github.event_name == 'pull_request' && github.event.action != 'closed' | |
| uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd | |
| with: | |
| script: | | |
| const alb = '${{ steps.tf-output.outputs.target_group }}'; | |
| const url = '${{ steps.tf-output.outputs.preview_url }}'; | |
| const proxy_url = 'https://internal-dev.api.service.nhs.uk/clinical-data-gateway-api-poc-pr-${{ github.event.pull_request.number }}'; | |
| const cluster = '${{ steps.tf-output.outputs.ecs_cluster }}'; | |
| const service = '${{ steps.tf-output.outputs.ecs_service }}'; | |
| 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 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 { 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}) — [Health endpoint](${url}/health)`, | |
| ` - Smoke Test: ${smokeReadable} (HTTP ${smokeStatus})`, | |
| `- Proxy URL: [${proxy_url}](${proxy_url})`, | |
| `- ECS Cluster: \`${cluster}\``, | |
| `- ECS Service: \`${service}\``, | |
| `- ALB Target: \`${alb}\``, | |
| ]; | |
| await github.rest.issues.createComment({ | |
| owner, | |
| repo, | |
| issue_number: issueNumber, | |
| body: lines.join('\n'), | |
| }); | |
| # ---------- Security scanning ---------- | |
| - name: Trivy IaC scan | |
| if: github.event.action != 'closed' | |
| uses: nhs-england-tools/trivy-action/iac-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 | |
| with: | |
| scan-ref: infrastructure/environments/preview | |
| artifact-name: trivy-iac-scan-${{ steps.meta.outputs.branch_name }} | |
| - name: Trivy image scan | |
| if: github.event.action != 'closed' | |
| uses: nhs-england-tools/trivy-action/image-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 | |
| with: | |
| image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} | |
| artifact-name: trivy-image-scan-${{ steps.meta.outputs.branch_name }} | |
| - name: Generate SBOM | |
| if: github.event.action != 'closed' | |
| uses: nhs-england-tools/trivy-action/image-scan@289984b2f03034233a347d6dbadecd5ca9ea9634 | |
| with: | |
| image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} | |
| artifact-name: trivy-sbom-${{ steps.meta.outputs.branch_name }} |