[GPCAPIM-278]: Deploy/redeploy a proxy instance for PRs #221
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/" | |
| python_version: "3.14" | |
| 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: | |
| AWS_ROLE_ARN: ${{ secrets.DEV_AWS_CREDENTIALS }} | |
| steps: | |
| - name: Checkout repo | |
| uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 | |
| # Configure AWS credentials (OIDC recommended) | |
| - name: Configure AWS credentials | |
| uses: aws-actions/configure-aws-credentials@4c2b9cc816c86555b61460789ac95da17d7e829b | |
| with: | |
| role-to-assume: ${{ env.AWS_ROLE_ARN }} | |
| aws-region: ${{ env.AWS_REGION }} | |
| - name: Login to Amazon ECR | |
| id: ecr-login | |
| uses: aws-actions/amazon-ecr-login@062b18b96a7aff071d4dc91bc00c4c1a7945b076 | |
| - name: Compute branch metadata | |
| id: meta | |
| run: | | |
| # For PRs, head_ref is the source branch name | |
| RAW_BRANCH="${GITHUB_HEAD_REF:-${GITHUB_REF_NAME}}" | |
| # 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 | |
| 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@b9cd54a3c349d3f38e8881555d616ced269862dd | |
| 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 \ | |
| -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: Install yq for YAML template processing | |
| if: github.event.action != 'closed' | |
| run: | | |
| sudo wget -qO /usr/local/bin/yq https://github.com/mikefarah/yq/releases/latest/download/yq_linux_amd64 | |
| sudo chmod +x /usr/local/bin/yq | |
| - name: Inject secrets into openapi.yaml for deploying proxy | |
| if: github.event.action != 'closed' | |
| working-directory: gateway-api | |
| env: | |
| MTLS_SECRET_KEY: ${{ secrets.MTLS_SECRET_KEY }} | |
| PREVIEW_URL: ${{ steps.tf-output.outputs.preview_url }} | |
| run: | | |
| cp openapi.template.yaml openapi.proxygen.yaml | |
| yq eval '.x-nhsd-apim.target.url = env(PREVIEW_URL) | .x-nhsd-apim.target.security.secret = env(MTLS_SECRET_KEY)' -i openapi.yaml | |
| - name: Install proxygen-cli | |
| if: github.event.action != 'closed' | |
| run: | | |
| pip install proxygen-cli | |
| proxygen --version | |
| - name: Get proxygen machine user details | |
| if: github.event.action != 'closed' | |
| 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: Apply proxygen details | |
| if: github.event.action != 'closed' | |
| working-directory: proxygen | |
| run: | | |
| cp settings.yaml $HOME/.proxygen/settings.yaml | |
| printf "%s" "$_cds_gateway_dev_proxygen_proxygen_key_secret" > /tmp/proxygen_private_key.pem | |
| cp credentials.template.yaml $HOME/.proxygen/credentials.yaml | |
| yq eval '.private_key_path = "/tmp/proxygen_private_key.pem"' -i $HOME/.proxygen/credentials.yaml | |
| proxygen instance list | |
| - name: Deploy preview API proxy | |
| if: github.event.action != 'closed' | |
| run: | | |
| # TODO | |
| - name: Tear down preview API proxy | |
| if: github.event.action == 'closed' | |
| run: | | |
| # TODO | |
| # ---------- 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 -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 | |
| - 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 | |
| # Reachability check: allow 404 (app routes might not exist yet) but fail otherwise | |
| 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 | |
| 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) | |
| 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: 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 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})`, | |
| `- 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 filesystem scan | |
| if: github.event.action != 'closed' | |
| uses: nhs-england-tools/trivy-action/image-scan@3456c1657a37d500027fd782e6b08911725392da | |
| with: | |
| image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} | |
| artifact-name: trivy-scan-${{ steps.meta.outputs.branch_name }} | |
| - name: Generate SBOM | |
| uses: nhs-england-tools/trivy-action/sbom-scan@3456c1657a37d500027fd782e6b08911725392da | |
| if: github.event.action != 'closed' | |
| with: | |
| image-ref: ${{steps.meta.outputs.ecr_url}}:${{steps.meta.outputs.branch_name}} | |
| artifact-name: trivy-sbom-${{ steps.meta.outputs.branch_name }} |