From 82d3efa1d04bc493aa3bbe715ec8a5e4081c1bda Mon Sep 17 00:00:00 2001 From: Josiel Souza Date: Wed, 18 Mar 2026 15:04:31 +0000 Subject: [PATCH] feat(iac): add CI/CD pipelines and security templates Adds Azure DevOps deployment pipelines for Arc infrastructure and gateway test VM. Also includes GitHub issue templates for support requests, feature requests, and bug reports, along with a security policy document. Updates the .gitignore to exclude Terraform state files and adds a new section in the Makefile for deploying applications. Refs: DTOSS-12318 --- .azuredevops/pipelines/deploy-app.yml | 49 ++++ .azuredevops/pipelines/deploy.yml | 55 ++++ .github/ISSUE_TEMPLATE/1_support_request.yaml | 52 ++++ .github/ISSUE_TEMPLATE/2_feature_request.yaml | 42 ++++ .github/ISSUE_TEMPLATE/3_bug_report.yaml | 63 +++++ .github/SECURITY.md | 34 +++ .github/workflows/cicd-1-pull-request.yaml | 42 +++- .github/workflows/cicd-2-main-branch.yaml | 32 ++- .github/workflows/stage-3-build.yaml | 23 +- .github/workflows/stage-4-deploy-app.yaml | 64 +++++ .github/workflows/stage-4-deploy.yaml | 59 +++++ .gitignore | 8 +- .tool-versions | 1 + Makefile | 10 +- docs/CODE_OF_CONDUCT.md | 76 ++++++ docs/deployment/16-Deploy-Script-Analysis.md | 9 +- .../environments/dev/variables.tfvars | 2 + .../environments/preprod/variables.sh | 6 +- .../environments/preprod/variables.tfvars | 1 + infrastructure/environments/prod/variables.sh | 3 +- .../environments/prod/variables.tfvars | 1 + .../environments/review/variables.tfvars | 2 + infrastructure/modules/arc-infra/arc.tf | 24 ++ .../modules/arc-infra/azure_monitor.tf | 128 ++++++++++ infrastructure/modules/arc-infra/outputs.tf | 38 +++ infrastructure/modules/arc-infra/providers.tf | 12 + infrastructure/modules/arc-infra/relay.tf | 59 +++++ infrastructure/modules/arc-infra/variables.tf | 30 +++ .../modules/gateway-test-vm/main.tf | 34 +++ .../modules/gateway-test-vm/networking.tf | 194 ++++++++++++++ .../modules/gateway-test-vm/outputs.tf | 29 +++ .../modules/gateway-test-vm/providers.tf | 13 + .../modules/gateway-test-vm/variables.tf | 44 ++++ infrastructure/modules/gateway-test-vm/vm.tf | 151 +++++++++++ infrastructure/terraform/main.tf | 40 +++ infrastructure/terraform/outputs.tf | 10 + infrastructure/terraform/providers.tf | 28 +++ .../terraform/resource_group_init/core.bicep | 17 +- infrastructure/terraform/variables.tf | 56 +++++ scripts/bash/deploy_arc_ring.sh | 183 ++++++++++++++ scripts/bash/deploy_stage.sh | 26 ++ scripts/bash/wait_ado_pipeline.sh | 44 ++++ scripts/bash/wait_arc_run_command.sh | 86 +++++++ scripts/powershell/arc-setup.ps1 | 236 ++++++++++++++++++ scripts/powershell/cleanup.ps1 | 4 +- scripts/powershell/deploy.ps1 | 190 +++++++------- scripts/terraform/terraform.mk | 15 +- src/relay_listener.py | 2 +- src/services/init_worklist_db.sql | 1 - uv.lock | 30 ++- 50 files changed, 2228 insertions(+), 130 deletions(-) create mode 100644 .azuredevops/pipelines/deploy-app.yml create mode 100644 .azuredevops/pipelines/deploy.yml create mode 100644 .github/ISSUE_TEMPLATE/1_support_request.yaml create mode 100644 .github/ISSUE_TEMPLATE/2_feature_request.yaml create mode 100644 .github/ISSUE_TEMPLATE/3_bug_report.yaml create mode 100644 .github/SECURITY.md create mode 100644 .github/workflows/stage-4-deploy-app.yaml create mode 100644 .github/workflows/stage-4-deploy.yaml create mode 100644 docs/CODE_OF_CONDUCT.md create mode 100644 infrastructure/environments/dev/variables.tfvars create mode 100644 infrastructure/environments/preprod/variables.tfvars create mode 100644 infrastructure/environments/prod/variables.tfvars create mode 100644 infrastructure/environments/review/variables.tfvars create mode 100644 infrastructure/modules/arc-infra/arc.tf create mode 100644 infrastructure/modules/arc-infra/azure_monitor.tf create mode 100644 infrastructure/modules/arc-infra/outputs.tf create mode 100644 infrastructure/modules/arc-infra/providers.tf create mode 100644 infrastructure/modules/arc-infra/relay.tf create mode 100644 infrastructure/modules/arc-infra/variables.tf create mode 100644 infrastructure/modules/gateway-test-vm/main.tf create mode 100644 infrastructure/modules/gateway-test-vm/networking.tf create mode 100644 infrastructure/modules/gateway-test-vm/outputs.tf create mode 100644 infrastructure/modules/gateway-test-vm/providers.tf create mode 100644 infrastructure/modules/gateway-test-vm/variables.tf create mode 100644 infrastructure/modules/gateway-test-vm/vm.tf create mode 100644 infrastructure/terraform/main.tf create mode 100644 infrastructure/terraform/outputs.tf create mode 100644 infrastructure/terraform/providers.tf create mode 100644 infrastructure/terraform/variables.tf create mode 100755 scripts/bash/deploy_arc_ring.sh create mode 100755 scripts/bash/deploy_stage.sh create mode 100755 scripts/bash/wait_ado_pipeline.sh create mode 100755 scripts/bash/wait_arc_run_command.sh create mode 100644 scripts/powershell/arc-setup.ps1 diff --git a/.azuredevops/pipelines/deploy-app.yml b/.azuredevops/pipelines/deploy-app.yml new file mode 100644 index 00000000..898877a6 --- /dev/null +++ b/.azuredevops/pipelines/deploy-app.yml @@ -0,0 +1,49 @@ +trigger: none +pr: none + +parameters: + - name: releaseTag + displayName: Release tag to deploy + type: string + + - name: environment + displayName: Environment + type: string + + - name: pool + displayName: ADO management pool + type: string + + +stages: + - stage: ${{ parameters.environment }} + displayName: Deploy gateway app to ${{ parameters.environment }} + pool: + name: ${{ parameters.pool }} + lockBehavior: sequential + isSkippable: false + + jobs: + - deployment: DeployGatewayApp + displayName: Deploy gateway app + environment: ${{ parameters.environment }} + strategy: + runOnce: + deploy: + steps: + - checkout: self + + - task: AzureCLI@2 + displayName: Deploy gateway app to ${{ parameters.environment }} + inputs: + azureSubscription: manbgw-${{ parameters.environment }} + scriptType: bash + scriptLocation: inlineScript + addSpnToEnvironment: true + inlineScript: | + export ARM_TENANT_ID="$tenantId" + export ARM_CLIENT_ID="$servicePrincipalId" + export ARM_OIDC_TOKEN="$idToken" + export ARM_USE_OIDC=true + export ARM_USE_AZUREAD=true + make ci ${{ parameters.environment }} deploy-app RELEASE_TAG="${{ parameters.releaseTag }}" diff --git a/.azuredevops/pipelines/deploy.yml b/.azuredevops/pipelines/deploy.yml new file mode 100644 index 00000000..51e79a4a --- /dev/null +++ b/.azuredevops/pipelines/deploy.yml @@ -0,0 +1,55 @@ +trigger: none +pr: none + +parameters: + - name: commitSHA + displayName: Commit SHA + type: string + - name: environment + displayName: Environment + type: string + - name: pool + displayName: ADO management pool + type: string + +stages: + - stage: ${{ parameters.environment }} + displayName: Deploy Arc infrastructure to ${{ parameters.environment }} + pool: + name: ${{ parameters.pool }} + lockBehavior: sequential + isSkippable: false + + jobs: + - deployment: DeployArcInfra + displayName: Deploy Arc infrastructure + environment: ${{ parameters.environment }} + strategy: + runOnce: + deploy: + steps: + - checkout: self + + - bash: | + TF_VERSION=$(grep "^terraform" .tool-versions | awk '{print $2}') + echo "##vso[task.setvariable variable=TF_VERSION]$TF_VERSION" + displayName: Get Terraform version from .tool-versions + + - task: TerraformInstaller@1 + displayName: Install Terraform + inputs: + terraformVersion: $(TF_VERSION) + + - task: AzureCLI@2 + displayName: Run Terraform + inputs: + azureSubscription: manbgw-${{ parameters.environment }} + scriptType: bash + scriptLocation: inlineScript + addSpnToEnvironment: true + inlineScript: | + export ARM_TENANT_ID="$tenantId" + export ARM_CLIENT_ID="$servicePrincipalId" + export ARM_OIDC_TOKEN="$idToken" + export ARM_USE_OIDC=true + make ci ${{ parameters.environment }} terraform-apply diff --git a/.github/ISSUE_TEMPLATE/1_support_request.yaml b/.github/ISSUE_TEMPLATE/1_support_request.yaml new file mode 100644 index 00000000..7741fd3b --- /dev/null +++ b/.github/ISSUE_TEMPLATE/1_support_request.yaml @@ -0,0 +1,52 @@ +# See: +# - https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository +# - https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms +# - https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/common-validation-errors-when-creating-issue-forms + +name: 🔧 Support Request +description: Get help +labels: ['support'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a support request. Please fill out this form as completely as possible. + - type: textarea + attributes: + label: What exactly are you trying to do? + description: Describe in as much detail as possible. + validations: + required: true + - type: textarea + attributes: + label: What have you tried so far? + description: Describe what you have tried so far. + validations: + required: true + - type: textarea + attributes: + label: Output of any commands you have tried + description: Please copy and paste any relevant output. This will be automatically formatted into codeblock. + render: Shell + validations: + required: false + - type: textarea + attributes: + label: Additional context + description: Add any other context about the problem here. + validations: + required: false + - type: checkboxes + attributes: + label: Code of Conduct + description: By submitting this issue you agree to follow our [Code of Conduct](../../docs/CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true + - type: checkboxes + attributes: + label: Sensitive Information Declaration + description: To ensure the utmost confidentiality and protect your privacy, we kindly ask you to NOT including [PII (Personal Identifiable Information) / PID (Personal Identifiable Data)](https://digital.nhs.uk/data-and-information/keeping-data-safe-and-benefitting-the-public) or any other sensitive data in this form. We appreciate your cooperation in maintaining the security of your information. + options: + - label: I confirm that neither PII/PID nor sensitive data are included in this form + required: true diff --git a/.github/ISSUE_TEMPLATE/2_feature_request.yaml b/.github/ISSUE_TEMPLATE/2_feature_request.yaml new file mode 100644 index 00000000..2eeeff9d --- /dev/null +++ b/.github/ISSUE_TEMPLATE/2_feature_request.yaml @@ -0,0 +1,42 @@ +# See: +# - https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository +# - https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms +# - https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/common-validation-errors-when-creating-issue-forms + +name: 🚀 Feature Request +description: Suggest an idea for this project +labels: ['feature request'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a feature request. Please fill out this form as completely as possible. + - type: textarea + attributes: + label: What is the problem this feature will solve? + description: Tell us why this change is needed or helpful and what problems it may help solve. + validations: + required: true + - type: textarea + attributes: + label: What is the feature that you are proposing to solve the problem? + description: Provide detailed information for what we should add. + validations: + required: true + - type: textarea + attributes: + label: What alternatives have you considered? + - type: checkboxes + attributes: + label: Code of Conduct + description: By submitting this issue you agree to follow our [Code of Conduct](../../docs/CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true + - type: checkboxes + attributes: + label: Sensitive Information Declaration + description: To ensure the utmost confidentiality and protect your privacy, we kindly ask you to NOT including [PII (Personal Identifiable Information) / PID (Personal Identifiable Data)](https://digital.nhs.uk/data-and-information/keeping-data-safe-and-benefitting-the-public) or any other sensitive data in this form. We appreciate your cooperation in maintaining the security of your information. + options: + - label: I confirm that neither PII/PID nor sensitive data are included in this form + required: true diff --git a/.github/ISSUE_TEMPLATE/3_bug_report.yaml b/.github/ISSUE_TEMPLATE/3_bug_report.yaml new file mode 100644 index 00000000..0447df79 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/3_bug_report.yaml @@ -0,0 +1,63 @@ +# See: +# - https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/configuring-issue-templates-for-your-repository +# - https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/syntax-for-issue-forms +# - https://docs.github.com/en/communities/using-templates-to-encourage-useful-issues-and-pull-requests/common-validation-errors-when-creating-issue-forms + +name: 🐞 Bug Report +description: File a bug report +labels: ['bug', 'triage'] +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to file a bug report. Please fill out this form as completely as possible. + - type: checkboxes + attributes: + label: Is there an existing issue for this? + description: Please, search the Issues to see if an issue already exists for the bug you have encountered. + options: + - label: I have searched the existing Issues + required: true + - type: textarea + attributes: + label: Current Behavior + description: A concise description of what you are experiencing. + validations: + required: true + - type: textarea + attributes: + label: Expected Behavior + description: A concise description of what you expect to happen. + validations: + required: true + - type: textarea + attributes: + label: Steps To Reproduce + description: Steps to reproduce the behavior + placeholder: | + 1. In this environment... + 2. With this config... + 3. Run `...` + validations: + required: false + - type: textarea + attributes: + label: Output + description: Please copy and paste any relevant output. This will be automatically formatted into codeblock. + render: Shell + validations: + required: false + - type: checkboxes + attributes: + label: Code of Conduct + description: By submitting this issue you agree to follow our [Code of Conduct](../../docs/CODE_OF_CONDUCT.md) + options: + - label: I agree to follow this project's Code of Conduct + required: true + - type: checkboxes + attributes: + label: Sensitive Information Declaration + description: To ensure the utmost confidentiality and protect your privacy, we kindly ask you to NOT including [PII (Personal Identifiable Information) / PID (Personal Identifiable Data)](https://digital.nhs.uk/data-and-information/keeping-data-safe-and-benefitting-the-public) or any other sensitive data in this form. We appreciate your cooperation in maintaining the security of your information. + options: + - label: I confirm that neither PII/PID nor sensitive data are included in this form + required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 00000000..480e970c --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,34 @@ +# Security + +NHS England takes security and the protection of private data extremely seriously. If you believe you have found a vulnerability or other issue which has compromised or could compromise the security of any of our systems and/or private data managed by our systems, please do not hesitate to contact us using the methods outlined below. + +## Table of Contents + +- [Security](#security) + - [Table of Contents](#table-of-contents) + - [Reporting a vulnerability](#reporting-a-vulnerability) + - [Email](#email) + - [NCSC](#ncsc) + - [General Security Enquiries](#general-security-enquiries) + +## Reporting a vulnerability + +Please note, email is our preferred method of receiving reports. + +### Email + +If you wish to notify us of a vulnerability via email, please include detailed information on the nature of the vulnerability and any steps required to reproduce it. + +You can reach us at: + +- [cybersecurity@nhs.net](cybersecurity@nhs.net) + +### NCSC + +You can send your report to the National Cyber Security Centre, who will assess your report and pass it on to NHS England if necessary. + +You can report vulnerabilities here: [https://www.ncsc.gov.uk/information/vulnerability-reporting](https://www.ncsc.gov.uk/information/vulnerability-reporting) + +## General Security Enquiries + +If you have general enquiries regarding our cybersecurity, please reach out to us at [cybersecurity@nhs.net](cybersecurity@nhs.net) diff --git a/.github/workflows/cicd-1-pull-request.yaml b/.github/workflows/cicd-1-pull-request.yaml index 2b7fc298..8115781f 100644 --- a/.github/workflows/cicd-1-pull-request.yaml +++ b/.github/workflows/cicd-1-pull-request.yaml @@ -2,14 +2,17 @@ name: "CI/CD: Pull Request" on: pull_request: + types: [opened, synchronize, reopened, labeled] concurrency: - group: cicd-${{ github.ref }} + group: pr-${{ github.ref }} cancel-in-progress: true permissions: - contents: read + contents: write # needed to create pre-releases + id-token: write security-events: write + attestations: write # needed by stage-3-build (attest-build-provenance) jobs: commit-stage: @@ -20,3 +23,38 @@ jobs: needs: commit-stage uses: ./.github/workflows/stage-2-test.yaml secrets: inherit + + deploy-stage: + name: Deploy stage + needs: [commit-stage, test-stage] + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy.yaml + with: + environments: '["review"]' + commit_sha: ${{ github.event.pull_request.head.sha }} + secrets: inherit + + # Build and publish a pre-release when the 'deploy' label is present. + # Runs on every push while the label is active (synchronize re-triggers this). + build-for-deploy: + name: Build for deploy + needs: [commit-stage, test-stage] + if: contains(github.event.pull_request.labels.*.name, 'deploy') + uses: ./.github/workflows/stage-3-build.yaml + with: + pr_number: ${{ github.event.pull_request.number }} + secrets: inherit + + deploy-app-stage: + name: Deploy app stage + needs: [build-for-deploy, deploy-stage] + if: contains(github.event.pull_request.labels.*.name, 'deploy') + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy-app.yaml + with: + environments: '["review"]' + release_tag: pr-${{ github.event.pull_request.number }} + commit_sha: ${{ github.event.pull_request.head.sha }} + secrets: inherit diff --git a/.github/workflows/cicd-2-main-branch.yaml b/.github/workflows/cicd-2-main-branch.yaml index 6b79c67c..f57d4d87 100644 --- a/.github/workflows/cicd-2-main-branch.yaml +++ b/.github/workflows/cicd-2-main-branch.yaml @@ -8,9 +8,7 @@ on: - 'v*' workflow_dispatch: -concurrency: - group: cicd-${{ github.ref }} - cancel-in-progress: true +concurrency: cicd-${{ github.ref }} permissions: contents: write @@ -98,3 +96,31 @@ jobs: version: ${{ needs.release-stage.outputs.new_release_version }} new_release_published: ${{ needs.release-stage.outputs.new_release_published == 'true' }} secrets: inherit + + deploy-stage: + name: Deploy stage + needs: [commit-stage, test-stage] + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy.yaml + with: + environments: '["review"]' + commit_sha: ${{ github.sha }} + secrets: inherit + + deploy-app-stage: + name: Deploy app stage + needs: [build-stage, release-stage] + # Only deploy when a new release was published — no release means no deployable artifact. + if: | + always() && + needs.build-stage.result == 'success' && + needs.release-stage.outputs.new_release_published == 'true' + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy-app.yaml + with: + environments: '["dev", "preprod", "prod"]' + release_tag: ${{ needs.release-stage.outputs.new_release_version }} + commit_sha: ${{ github.sha }} + secrets: inherit diff --git a/.github/workflows/stage-3-build.yaml b/.github/workflows/stage-3-build.yaml index 9f73453f..4229958a 100644 --- a/.github/workflows/stage-3-build.yaml +++ b/.github/workflows/stage-3-build.yaml @@ -12,6 +12,11 @@ on: required: false type: boolean default: false + pr_number: + description: "PR number for pre-release build (e.g. '42' → tag 'pr-42'). Empty = not a PR build." + required: false + type: string + default: "" permissions: contents: write @@ -31,7 +36,9 @@ jobs: - name: Determine version id: version run: | - if [[ -n "${{ inputs.version }}" ]]; then + if [[ -n "${{ inputs.pr_number }}" ]]; then + VERSION="pr-${{ inputs.pr_number }}" + elif [[ -n "${{ inputs.version }}" ]]; then VERSION="${{ inputs.version }}" elif [[ "${{ github.ref_type }}" == "tag" ]]; then VERSION="${{ github.ref_name }}" @@ -77,14 +84,24 @@ jobs: " - name: Create or Update Release - if: github.ref_type == 'tag' || inputs.new_release_published + if: github.ref_type == 'tag' || inputs.new_release_published || inputs.pr_number != '' env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} VERSION: ${{ steps.version.outputs.version }} + IS_PRERELEASE: ${{ inputs.pr_number != '' }} run: | - # Create the release if it doesn't exist. If it does, ignore the error. + PRERELEASE_FLAG="" + [[ "$IS_PRERELEASE" == "true" ]] && PRERELEASE_FLAG="--prerelease" + + # Pre-releases are recreated on every push so the git tag always points + # to the current commit (raw.githubusercontent.com uses the tag as a ref). + if [[ "$IS_PRERELEASE" == "true" ]]; then + gh release delete "$VERSION" --yes --cleanup-tag 2>/dev/null || true + fi + gh release create "$VERSION" \ --title "$VERSION" \ + $PRERELEASE_FLAG \ --generate-notes \ 2>/dev/null || echo "Release already exists, uploading assets..." diff --git a/.github/workflows/stage-4-deploy-app.yaml b/.github/workflows/stage-4-deploy-app.yaml new file mode 100644 index 00000000..aa3b098a --- /dev/null +++ b/.github/workflows/stage-4-deploy-app.yaml @@ -0,0 +1,64 @@ +name: Deployment stage (app) + +on: + workflow_call: + inputs: + environments: + description: List of environments to deploy to (JSON string array) + required: true + type: string + release_tag: + description: GitHub release tag to deploy (e.g. v1.2.3 or pr-42) + required: true + type: string + commit_sha: + description: Commit SHA used to trigger the ADO pipeline + required: true + type: string + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + strategy: + matrix: + environment: ${{ fromJson(inputs.environments) }} + max-parallel: 1 + environment: ${{ matrix.environment }} + # Prevent concurrent app deployments to the same environment + concurrency: deploy-app-${{ matrix.environment }} + permissions: + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Azure login + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Call deployment pipeline + env: + ENVIRONMENT: ${{ matrix.environment }} + RELEASE_TAG: ${{ inputs.release_tag }} + COMMIT_SHA: ${{ inputs.commit_sha }} + ADO_PROJECT: ${{ github.event.repository.name }} + run: | + source "infrastructure/environments/${ENVIRONMENT}/variables.sh" + + echo "Starting Azure DevOps pipeline \"Deploy Gateway App - ${ENVIRONMENT}\"..." + RUN_ID=$(az pipelines run \ + --commit-id "${COMMIT_SHA}" \ + --name "Deploy Gateway App - ${ENVIRONMENT}" \ + --org https://dev.azure.com/nhse-dtos \ + --project "${ADO_PROJECT}" \ + --parameters releaseTag="${RELEASE_TAG}" environment="${ENVIRONMENT}" pool="${ADO_MANAGEMENT_POOL}" \ + --output tsv --query id) + + echo "See pipeline run in Azure DevOps: https://dev.azure.com/nhse-dtos/${ADO_PROJECT}/_build/results?buildId=${RUN_ID}&view=results" + + scripts/bash/wait_ado_pipeline.sh "$RUN_ID" https://dev.azure.com/nhse-dtos "${ADO_PROJECT}" diff --git a/.github/workflows/stage-4-deploy.yaml b/.github/workflows/stage-4-deploy.yaml new file mode 100644 index 00000000..03abd3e2 --- /dev/null +++ b/.github/workflows/stage-4-deploy.yaml @@ -0,0 +1,59 @@ +name: Deployment stage + +on: + workflow_call: + inputs: + environments: + description: List of environments to deploy to (JSON string array) + required: true + type: string + commit_sha: + description: Commit SHA used to trigger the ADO pipeline + required: true + type: string + +jobs: + deploy: + name: Deploy + runs-on: ubuntu-latest + strategy: + matrix: + environment: ${{ fromJson(inputs.environments) }} + max-parallel: 1 + environment: ${{ matrix.environment }} + # Prevent concurrent deployments to the same environment + concurrency: deploy-${{ matrix.environment }} + permissions: + id-token: write + + steps: + - name: Checkout code + uses: actions/checkout@11bd71901bbe5b1630ceea73d27597364c9af683 # v4.2.2 + + - name: Azure login + uses: azure/login@a457da9ea143d694b1b9c7c869ebb04ebe844ef5 # v2 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Call deployment pipeline + env: + ENVIRONMENT: ${{ matrix.environment }} + COMMIT_SHA: ${{ inputs.commit_sha }} + ADO_PROJECT: ${{ github.event.repository.name }} + run: | + source "infrastructure/environments/${ENVIRONMENT}/variables.sh" + + echo "Starting Azure devops pipeline \"Deploy to Azure - ${ENVIRONMENT}\"..." + RUN_ID=$(az pipelines run \ + --commit-id "${COMMIT_SHA}" \ + --name "Deploy to Azure - ${ENVIRONMENT}" \ + --org https://dev.azure.com/nhse-dtos \ + --project "${ADO_PROJECT}" \ + --parameters commitSHA="${COMMIT_SHA}" environment="${ENVIRONMENT}" pool="${ADO_MANAGEMENT_POOL}" \ + --output tsv --query id) + + echo "See pipeline run in Azure devops: https://dev.azure.com/nhse-dtos/${ADO_PROJECT}/_build/results?buildId=${RUN_ID}&view=results" + + scripts/bash/wait_ado_pipeline.sh "$RUN_ID" https://dev.azure.com/nhse-dtos "${ADO_PROJECT}" diff --git a/.gitignore b/.gitignore index e59cd871..7a17fe53 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,13 @@ lines-of-code-report.json # Please, add your custom content below! +# Terraform +.terraform/ +*.tfstate +*.tfstate.backup +*.tfplan +infrastructure/modules/dtos-devops-templates/ + # Python __pycache__/ *.py[cod] @@ -60,4 +67,3 @@ mediafiles/ # Documentation .structurizr workspace.json - diff --git a/.tool-versions b/.tool-versions index f59742e4..1ab4c26a 100644 --- a/.tool-versions +++ b/.tool-versions @@ -6,3 +6,4 @@ uv 0.9.7 ruff 0.14.1 pre-commit 3.6.0 gitleaks 8.18.4 +terraform 1.14.0 diff --git a/Makefile b/Makefile index 1a839db8..b117caa2 100644 --- a/Makefile +++ b/Makefile @@ -4,7 +4,9 @@ include scripts/terraform/terraform.mk .DEFAULT_GOAL := help .PHONY: clean config dependencies githooks-config githooks-run help test test-lint test-unit _install-uv \ - up down restart logs build rebuild run-pacs run-mwl run-listener run-upload ps shell docker-clean + up down restart logs build rebuild run-pacs run-mwl run-listener run-upload ps shell docker-clean \ + package deploy-app \ + review dev preprod prod .SILENT: help # --------------------------------------------------------------------------- @@ -20,6 +22,12 @@ help: # Print help @Others package: # Create release package @Operations ./scripts/bash/package_release.sh +GATEWAY_RINGS ?= ring0 + +deploy-app: # Deploy gateway app to the target environment - make deploy-app RELEASE_TAG= @Operations + @[ -n "$(RELEASE_TAG)" ] || (echo "ERROR: RELEASE_TAG is required. Usage: make deploy-app RELEASE_TAG="; exit 1) + scripts/bash/deploy_stage.sh "$(ENV_CONFIG)" "$(GATEWAY_RINGS)" "$(RELEASE_TAG)" + clean: # Clean-up project resources @Operations @echo "Cleaning up..." rm -rf .pytest_cache __pycache__ .coverage htmlcov/ .mypy_cache .ruff_cache diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 00000000..65cf5ffe --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,76 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +In the interest of fostering an open and welcoming environment, we as +contributors and maintainers pledge to making participation in our project and +our community a harassment-free experience for everyone, regardless of age, body +size, disability, ethnicity, sex characteristics, gender identity and expression, +level of experience, education, socio-economic status, nationality, personal +appearance, race, religion, or sexual identity and orientation. + +## Our Standards + +Examples of behavior that contributes to creating a positive environment +include: + +* Using welcoming and inclusive language +* Being respectful of differing viewpoints and experiences +* Gracefully accepting constructive criticism +* Focusing on what is best for the community +* Showing empathy towards other community members + +Examples of unacceptable behavior by participants include: + +* The use of sexualized language or imagery and unwelcome sexual attention or + advances +* Trolling, insulting/derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or electronic + address, without explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Our Responsibilities + +Project maintainers are responsible for clarifying the standards of acceptable +behavior and are expected to take appropriate and fair corrective action in +response to any instances of unacceptable behavior. + +Project maintainers have the right and responsibility to remove, edit, or +reject comments, commits, code, wiki edits, issues, and other contributions +that are not aligned to this Code of Conduct, or to ban temporarily or +permanently any contributor for other behaviors that they deem inappropriate, +threatening, offensive, or harmful. + +## Scope + +This Code of Conduct applies both within project spaces and in public spaces +when an individual is representing the project or its community. Examples of +representing a project or community include using an official project e-mail +address, posting via an official social media account, or acting as an appointed +representative at an online or offline event. Representation of a project may be +further defined and clarified by project maintainers. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported by contacting the project team. All +complaints will be reviewed and investigated and will result in a response that +is deemed necessary and appropriate to the circumstances. The project team is +obligated to maintain confidentiality with regard to the reporter of an incident. +Further details of specific enforcement policies may be posted separately. + +Project maintainers who do not follow or enforce the Code of Conduct in good +faith may face temporary or permanent repercussions as determined by other +members of the project's leadership. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], version 1.4, +available at https://www.contributor-covenant.org/version/1/4/code-of-conduct.html + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see +https://www.contributor-covenant.org/faq diff --git a/docs/deployment/16-Deploy-Script-Analysis.md b/docs/deployment/16-Deploy-Script-Analysis.md index 8b1174dd..2bbb9a75 100644 --- a/docs/deployment/16-Deploy-Script-Analysis.md +++ b/docs/deployment/16-Deploy-Script-Analysis.md @@ -30,13 +30,11 @@ Lines 49–68 ### Step 2: Helper Functions ```text -Lines 70–163 +Lines 70–100 ``` **What it does**: Defines internal helpers: - `Invoke-Nssm` — Wraps every NSSM call and checks `$LASTEXITCODE`. Throws immediately with the full command string if NSSM returns a non-zero exit code. -- `Get-PythonVersionFromPyproject` — Parses `requires-python` from `pyproject.toml`. -- `Get-PythonVersionFromZip` — Reads `pyproject.toml` directly from a ZIP archive. - `Stop-AllServices` — Gracefully stops all components with individual timeout enforcement. **Why it matters**: NSSM is a native executable and PowerShell does not treat its non-zero exit codes as errors by default. Without explicit checking, a failed `nssm install` or `nssm set` would silently proceed, creating a service that is misconfigured. The helper eliminates this class of bug across all ~12 NSSM invocations. @@ -49,12 +47,11 @@ Lines 70–163 Lines 165–206 ``` -**What it does**: When `-Bootstrap` is passed, installs Chocolatey (if absent), Python, uv, and NSSM. Each tool is only installed if not already present in PATH. The Python version is resolved from a chain of sources: `-PythonVersion` parameter, local `pyproject.toml`, or `pyproject.toml` read directly from the ZIP archive. +**What it does**: When `-Bootstrap` is passed, installs Chocolatey (if absent), Python, uv, and NSSM. Each tool is only installed if not already present in PATH. The Python version comes from the `-PythonVersion` parameter (sourced from `.tool-versions` in the repo via the deploy pipeline), with a fallback to `3.14.0` if the parameter is not provided. **Why it matters**: - **Idempotent**: Running bootstrap twice does not reinstall tools that are already present (including Python). This makes the script safe to re-run after a partial failure without triggering Chocolatey "already installed" errors. -- **Dynamic Python version**: Rather than hardcoding a Python version, the script resolves it from a three-level chain: explicit parameter, local `pyproject.toml`, or the `pyproject.toml` inside the deployment archive. This means the packaging pipeline is the single source of truth. -- **Fails fast**: If no version can be determined from any source, the script throws before attempting any installation. +- **Single source of truth**: The Python version is always passed from `.tool-versions` at the root of the repo, read by `deploy_arc_ring.sh` at deploy time. This ensures the VM always runs the same Python version as CI. --- diff --git a/infrastructure/environments/dev/variables.tfvars b/infrastructure/environments/dev/variables.tfvars new file mode 100644 index 00000000..cba1ad7a --- /dev/null +++ b/infrastructure/environments/dev/variables.tfvars @@ -0,0 +1,2 @@ +vnet_address_space = "10.130.0.0/16" +enable_gateway_test_vm = true diff --git a/infrastructure/environments/preprod/variables.sh b/infrastructure/environments/preprod/variables.sh index 044cf37e..ae68acc3 100644 --- a/infrastructure/environments/preprod/variables.sh +++ b/infrastructure/environments/preprod/variables.sh @@ -1,8 +1,8 @@ ENV_CONFIG=preprod ENVIRONMENT=preprod -AZURE_SUBSCRIPTION="Breast Screening - Manage Breast Screening - Preprod" +AZURE_SUBSCRIPTION="Breast Screening - Manage Breast Screening - PreProd" HUB_SUBSCRIPTION="Digital Screening DToS - Core Services Prod Hub" -HUB=prod +HUB=preprod TERRAFORM_MODULES_REF=main -ENABLE_SOFT_DELETE=false +ENABLE_SOFT_DELETE=true ADO_MANAGEMENT_POOL=private-pool-prod-uks diff --git a/infrastructure/environments/preprod/variables.tfvars b/infrastructure/environments/preprod/variables.tfvars new file mode 100644 index 00000000..f560dcbc --- /dev/null +++ b/infrastructure/environments/preprod/variables.tfvars @@ -0,0 +1 @@ +# All values use defaults: enable_gateway_test_vm = false, enable_arc_servers = true diff --git a/infrastructure/environments/prod/variables.sh b/infrastructure/environments/prod/variables.sh index 4f679115..14d6883b 100644 --- a/infrastructure/environments/prod/variables.sh +++ b/infrastructure/environments/prod/variables.sh @@ -4,5 +4,6 @@ AZURE_SUBSCRIPTION="Breast Screening - Manage Breast Screening - Prod" HUB_SUBSCRIPTION="Digital Screening DToS - Core Services Prod Hub" HUB=prod TERRAFORM_MODULES_REF=main -ENABLE_SOFT_DELETE=false +ENABLE_SOFT_DELETE=true ADO_MANAGEMENT_POOL=private-pool-prod-uks +# To onboard more rings: set GATEWAY_RINGS="ring0 ring1 ring2 ..." in this file diff --git a/infrastructure/environments/prod/variables.tfvars b/infrastructure/environments/prod/variables.tfvars new file mode 100644 index 00000000..f560dcbc --- /dev/null +++ b/infrastructure/environments/prod/variables.tfvars @@ -0,0 +1 @@ +# All values use defaults: enable_gateway_test_vm = false, enable_arc_servers = true diff --git a/infrastructure/environments/review/variables.tfvars b/infrastructure/environments/review/variables.tfvars new file mode 100644 index 00000000..bb097c1f --- /dev/null +++ b/infrastructure/environments/review/variables.tfvars @@ -0,0 +1,2 @@ +vnet_address_space = "10.131.0.0/16" +enable_gateway_test_vm = true diff --git a/infrastructure/modules/arc-infra/arc.tf b/infrastructure/modules/arc-infra/arc.tf new file mode 100644 index 00000000..d926b030 --- /dev/null +++ b/infrastructure/modules/arc-infra/arc.tf @@ -0,0 +1,24 @@ +# Resource group for Arc-enabled servers (where machines register on onboarding) +resource "azurerm_resource_group" "arc_enabled_servers" { + count = var.enable_arc_servers ? 1 : 0 + + name = "${var.resource_group_name}-arc-enabled-servers" + location = var.region +} + +# Look up the Arc onboarding service principal by its standard name +data "azuread_service_principal" "arc_onboarding" { + count = var.enable_arc_servers ? 1 : 0 + display_name = "spn-azure-arc-onboarding-screening-${var.env_config}" +} + +# Assign "Azure Connected Machine Onboarding" role to allow Arc server enrollment +module "arc_onboarding_role" { + count = var.enable_arc_servers ? 1 : 0 + + source = "../dtos-devops-templates/infrastructure/modules/rbac-assignment" + + scope = azurerm_resource_group.arc_enabled_servers[0].id + role_definition_name = "Azure Connected Machine Onboarding" + principal_id = data.azuread_service_principal.arc_onboarding[0].object_id +} diff --git a/infrastructure/modules/arc-infra/azure_monitor.tf b/infrastructure/modules/arc-infra/azure_monitor.tf new file mode 100644 index 00000000..49e2f44b --- /dev/null +++ b/infrastructure/modules/arc-infra/azure_monitor.tf @@ -0,0 +1,128 @@ +# Log Analytics Workspace +module "log_analytics_workspace" { + count = var.enable_arc_servers ? 1 : 0 + source = "../dtos-devops-templates/infrastructure/modules/log-analytics-workspace" + + name = "law-${var.app_short_name}-${var.env_config}-arc-uks" + location = var.region + resource_group_name = azurerm_resource_group.arc_enabled_servers[0].name + law_sku = "PerGB2018" + retention_days = 30 + + monitor_diagnostic_setting_log_analytics_workspace_enabled_logs = ["Audit"] + monitor_diagnostic_setting_log_analytics_workspace_metrics = ["AllMetrics"] +} + +# Data Collection Rule (DCR) +resource "azurerm_monitor_data_collection_rule" "arc" { + count = var.enable_arc_servers ? 1 : 0 + + name = "dcr-${var.app_short_name}-${var.env_config}-arc-uks" + location = var.region + resource_group_name = azurerm_resource_group.arc_enabled_servers[0].name + + destinations { + log_analytics { + workspace_resource_id = module.log_analytics_workspace[0].id + name = "law-destination" + } + } + + data_flow { + streams = ["Microsoft-Event", "Microsoft-Perf"] + destinations = ["law-destination"] + } + + data_sources { + windows_event_log { + name = "windows-events" + streams = ["Microsoft-Event"] + x_path_queries = [ + "System!*[System[(Level=1 or Level=2 or Level=3)]]", + "Application!*[System[(Level=1 or Level=2 or Level=3)]]", + ] + } + + performance_counter { + name = "perf-counters" + streams = ["Microsoft-Perf"] + sampling_frequency_in_seconds = 60 + counter_specifiers = [ + "\\Processor(_Total)\\% Processor Time", + "\\Memory\\Available MBytes", + "\\LogicalDisk(_Total)\\% Free Space", + "\\LogicalDisk(_Total)\\Disk Reads/sec", + "\\LogicalDisk(_Total)\\Disk Writes/sec", + "\\Network Interface(*)\\Bytes Total/sec", + ] + } + } +} + +# Managed Identity for policy remediation +module "arc_monitor_policy_identity" { + count = var.enable_arc_servers ? 1 : 0 + source = "../dtos-devops-templates/infrastructure/modules/managed-identity" + + uai_name = "mi-${var.app_short_name}-${var.env_config}-arc-monitor-uks" + resource_group_name = azurerm_resource_group.arc_enabled_servers[0].name + location = var.region +} + +# Grants the identity permission to deploy AMA on Arc-enabled servers +module "arc_monitor_policy_connected_machine_role" { + count = var.enable_arc_servers ? 1 : 0 + source = "../dtos-devops-templates/infrastructure/modules/rbac-assignment" + + scope = azurerm_resource_group.arc_enabled_servers[0].id + role_definition_name = "Azure Connected Machine Resource Administrator" + principal_id = module.arc_monitor_policy_identity[0].principal_id +} + +# Grants the identity permission to create DCR associations and configure workspaces +module "arc_monitor_policy_log_analytics_role" { + count = var.enable_arc_servers ? 1 : 0 + source = "../dtos-devops-templates/infrastructure/modules/rbac-assignment" + + scope = azurerm_resource_group.arc_enabled_servers[0].id + role_definition_name = "Log Analytics Contributor" + principal_id = module.arc_monitor_policy_identity[0].principal_id +} + +# --------------------------------------------------------------------------- +# Policy Assignments for Arc-enabled servers +# AMA install: "Configure Windows Arc-enabled machines to run Azure Monitor Agent" +# DCR associate: "Configure Windows Arc-enabled machines to be associated with a DCR" +# --------------------------------------------------------------------------- +resource "azurerm_resource_group_policy_assignment" "ama_install" { + count = var.enable_arc_servers ? 1 : 0 + + name = "ama-install-arc-${var.env_config}" + resource_group_id = azurerm_resource_group.arc_enabled_servers[0].id + policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/4efbd9d8-6bc6-45f6-9be2-7fe9dd5d89ff" + location = var.region + + identity { + type = "UserAssigned" + identity_ids = [module.arc_monitor_policy_identity[0].id] + } +} + +resource "azurerm_resource_group_policy_assignment" "dcr_association" { + count = var.enable_arc_servers ? 1 : 0 + + name = "dcr-assoc-arc-${var.env_config}" + resource_group_id = azurerm_resource_group.arc_enabled_servers[0].id + policy_definition_id = "/providers/Microsoft.Authorization/policyDefinitions/eab1f514-22e3-42e3-9a1f-e1dc9199355c" + location = var.region + + identity { + type = "UserAssigned" + identity_ids = [module.arc_monitor_policy_identity[0].id] + } + + parameters = jsonencode({ + dcrResourceId = { value = azurerm_monitor_data_collection_rule.arc[0].id } + resourceType = { value = "Microsoft.Insights/dataCollectionRules" } + }) +} diff --git a/infrastructure/modules/arc-infra/outputs.tf b/infrastructure/modules/arc-infra/outputs.tf new file mode 100644 index 00000000..439c2569 --- /dev/null +++ b/infrastructure/modules/arc-infra/outputs.tf @@ -0,0 +1,38 @@ +output "arc_enabled_servers_resource_group_name" { + description = "Name of the Arc-enabled servers resource group" + value = var.enable_arc_servers ? azurerm_resource_group.arc_enabled_servers[0].name : null +} + +output "arc_enabled_servers_resource_group_id" { + description = "ID of the Arc-enabled servers resource group" + value = var.enable_arc_servers ? azurerm_resource_group.arc_enabled_servers[0].id : null +} + +output "arc_onboarding_spn_client_id" { + description = "Client ID of the Arc onboarding service principal" + value = var.enable_arc_servers ? data.azuread_service_principal.arc_onboarding[0].client_id : null +} + +output "arc_log_analytics_workspace_id" { + description = "ID of the Arc Log Analytics workspace (null when enable_arc_servers is false)" + value = var.enable_arc_servers ? module.log_analytics_workspace[0].id : null +} + +output "arc_log_analytics_workspace_name" { + description = "Name of the Arc Log Analytics workspace (null when enable_arc_servers is false)" + value = var.enable_arc_servers ? module.log_analytics_workspace[0].name : null +} + +output "relay_namespace_hostname" { + description = "Relay namespace FQDN for AZURE_RELAY_NAMESPACE in the gateway .env" + value = var.enable_arc_servers ? "${local.relay_namespace_name}.servicebus.windows.net" : null +} + +output "relay_listen_sas_keys" { + description = "Per-machine relay listen SAS primary keys, keyed by Arc resource name (SiteCode). Used by the deploy pipeline to write .env files." + sensitive = true + value = var.enable_arc_servers ? { + for k, rule in azurerm_relay_hybrid_connection_authorization_rule.per_machine_listen : + k => rule.primary_key + } : {} +} diff --git a/infrastructure/modules/arc-infra/providers.tf b/infrastructure/modules/arc-infra/providers.tf new file mode 100644 index 00000000..e02758cd --- /dev/null +++ b/infrastructure/modules/arc-infra/providers.tf @@ -0,0 +1,12 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "4.60.0" + } + azuread = { + source = "hashicorp/azuread" + version = "3.4.0" + } + } +} diff --git a/infrastructure/modules/arc-infra/relay.tf b/infrastructure/modules/arc-infra/relay.tf new file mode 100644 index 00000000..a98e51f9 --- /dev/null +++ b/infrastructure/modules/arc-infra/relay.tf @@ -0,0 +1,59 @@ +# The relay namespace is owned by dtos-manage-breast-screening ("manbrs"). +# This module creates one Hybrid Connection + listen-only auth rule per Arc-enabled +# machine, auto-discovered by querying the Arc resource group. +# HC names are derived from the Arc resource name (= SiteCode set at onboarding). +# +# Trigger: run `terraform apply` after each Arc onboarding to pick up new machines. + +locals { + relay_namespace_rg = "rg-manbrs-${var.env_config}-uks" + relay_namespace_name = "relay-manbrs-${var.env_config}" +} + +# Discover all Arc-enabled machines registered in the Arc resource group. +# Each machine's name is the SiteCode set during onboarding (e.g. gw-RVJ-01). +data "azurerm_resources" "arc_machines" { + count = var.enable_arc_servers ? 1 : 0 + + resource_group_name = azurerm_resource_group.arc_enabled_servers[0].name + type = "Microsoft.HybridCompute/machines" +} + +locals { + arc_machines_discovered = var.enable_arc_servers ? { + for m in data.azurerm_resources.arc_machines[0].resources : m.name => m + } : {} + + # Static machines (e.g. test VM) whose Arc registration happens in the same + # Terraform run — the data source won't see them yet, so we add them explicitly. + arc_machines_static = { + for name in var.static_arc_machine_names : name => { name = name } + } + + arc_machines = merge(local.arc_machines_discovered, local.arc_machines_static) +} + +# One Hybrid Connection per Arc machine (e.g. hc-gw-RVJ-01). +resource "azurerm_relay_hybrid_connection" "per_machine" { + for_each = local.arc_machines + + name = "hc-${each.key}" + resource_group_name = local.relay_namespace_rg + relay_namespace_name = local.relay_namespace_name + requires_client_authorization = true +} + +# Listen-only SAS rule per HC — distributed to each gateway site via the deploy pipeline. +# The cloud app uses a namespace-level Send SAS and does not need per-site keys. +resource "azurerm_relay_hybrid_connection_authorization_rule" "per_machine_listen" { + for_each = local.arc_machines + + name = "listen" + hybrid_connection_name = azurerm_relay_hybrid_connection.per_machine[each.key].name + namespace_name = local.relay_namespace_name + resource_group_name = local.relay_namespace_rg + + listen = true + send = false + manage = false +} diff --git a/infrastructure/modules/arc-infra/variables.tf b/infrastructure/modules/arc-infra/variables.tf new file mode 100644 index 00000000..412cfe8b --- /dev/null +++ b/infrastructure/modules/arc-infra/variables.tf @@ -0,0 +1,30 @@ +variable "region" { + description = "Azure region for all resources" + type = string +} + +variable "app_short_name" { + description = "Application short name used in resource naming (e.g., mbsgw)" + type = string +} + +variable "env_config" { + description = "Environment configuration name (e.g., dev, review)" + type = string +} + +variable "resource_group_name" { + description = "Base resource group name — used to derive the Arc-enabled servers RG name" + type = string +} + +variable "enable_arc_servers" { + description = "Whether to create Arc-enabled server resource groups and role assignments" + type = bool +} + +variable "static_arc_machine_names" { + description = "Machine names to create HCs for in addition to dynamically-discovered Arc machines. Used for the test VM whose Arc registration completes in the same Terraform run as HC creation." + type = list(string) + default = [] +} diff --git a/infrastructure/modules/gateway-test-vm/main.tf b/infrastructure/modules/gateway-test-vm/main.tf new file mode 100644 index 00000000..788b02f4 --- /dev/null +++ b/infrastructure/modules/gateway-test-vm/main.tf @@ -0,0 +1,34 @@ +# Resource group for all gateway test VM infrastructure +resource "azurerm_resource_group" "main" { + name = var.resource_group_name + location = var.region +} + +# Look up the environment Entra ID group for VM login access +data "azuread_group" "vm_login" { + display_name = "screening_mbsgw_${var.env_config}" +} + +# Grant Virtual Machine Administrator Login to the Entra ID group on the resource group. +# Required for Bastion to offer the Microsoft Entra ID authentication option. +module "vm_login_role" { + source = "../dtos-devops-templates/infrastructure/modules/rbac-assignment" + + scope = azurerm_resource_group.main.id + role_definition_name = "Virtual Machine Administrator Login" + principal_id = data.azuread_group.vm_login.object_id +} + +# Log Analytics workspace for monitoring VNet, Bastion, and the test VM +module "log_analytics_workspace" { + source = "../dtos-devops-templates/infrastructure/modules/log-analytics-workspace" + + name = "log-${var.app_short_name}-${var.env_config}-uks" + location = var.region + resource_group_name = azurerm_resource_group.main.name + law_sku = "PerGB2018" + retention_days = 30 + + monitor_diagnostic_setting_log_analytics_workspace_enabled_logs = [] + monitor_diagnostic_setting_log_analytics_workspace_metrics = ["AllMetrics"] +} diff --git a/infrastructure/modules/gateway-test-vm/networking.tf b/infrastructure/modules/gateway-test-vm/networking.tf new file mode 100644 index 00000000..7a71f0a4 --- /dev/null +++ b/infrastructure/modules/gateway-test-vm/networking.tf @@ -0,0 +1,194 @@ +# Virtual network for gateway test VM infrastructure +module "vnet" { + source = "../dtos-devops-templates/infrastructure/modules/vnet" + + name = "vnet-${var.app_short_name}-${var.env_config}-uks" + resource_group_name = azurerm_resource_group.main.name + location = var.region + vnet_address_space = var.vnet_address_space + + log_analytics_workspace_id = module.log_analytics_workspace.id + monitor_diagnostic_setting_vnet_enabled_logs = ["VMProtectionAlerts"] + monitor_diagnostic_setting_vnet_metrics = ["AllMetrics"] +} + +# NSG rules for the Bastion subnet per Azure documentation: +# https://learn.microsoft.com/en-us/azure/bastion/bastion-nsg +locals { + bastion_nsg_rules = [ + { + name = "AllowHttpsInbound" + priority = 120 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "443" + source_address_prefix = "Internet" + destination_address_prefix = "*" + }, + { + name = "AllowGatewayManagerInbound" + priority = 130 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "443" + source_address_prefix = "GatewayManager" + destination_address_prefix = "*" + }, + { + name = "AllowAzureLoadBalancerInbound" + priority = 140 + direction = "Inbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "443" + source_address_prefix = "AzureLoadBalancer" + destination_address_prefix = "*" + }, + { + name = "AllowBastionHostCommunicationInbound" + priority = 150 + direction = "Inbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_ranges = ["8080", "5701"] + source_address_prefix = "VirtualNetwork" + destination_address_prefix = "VirtualNetwork" + }, + { + name = "DenyAllInbound" + priority = 1000 + direction = "Inbound" + access = "Deny" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + }, + { + name = "AllowSshRdpOutbound" + priority = 100 + direction = "Outbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_ranges = ["22", "3389"] + source_address_prefix = "*" + destination_address_prefix = "VirtualNetwork" + }, + { + name = "AllowAzureCloudOutbound" + priority = 110 + direction = "Outbound" + access = "Allow" + protocol = "Tcp" + source_port_range = "*" + destination_port_range = "443" + source_address_prefix = "*" + destination_address_prefix = "AzureCloud" + }, + { + name = "AllowBastionHostCommunicationOutbound" + priority = 120 + direction = "Outbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_ranges = ["8080", "5701"] + source_address_prefix = "VirtualNetwork" + destination_address_prefix = "VirtualNetwork" + }, + { + name = "AllowGetSessionInformationOutbound" + priority = 130 + direction = "Outbound" + access = "Allow" + protocol = "*" + source_port_range = "*" + destination_port_range = "80" + source_address_prefix = "*" + destination_address_prefix = "Internet" + }, + { + name = "DenyAllOutbound" + priority = 1000 + direction = "Outbound" + access = "Deny" + protocol = "*" + source_port_range = "*" + destination_port_range = "*" + source_address_prefix = "*" + destination_address_prefix = "*" + }, + ] +} + +# Subnet for Azure Bastion — name must be exactly "AzureBastionSubnet" +# cidrsubnet(vnet/16, 10, 0) → /26 (minimum required for Bastion) +module "subnet_bastion" { + source = "../dtos-devops-templates/infrastructure/modules/subnet" + + name = "AzureBastionSubnet" + resource_group_name = azurerm_resource_group.main.name + vnet_name = module.vnet.name + address_prefixes = [cidrsubnet(var.vnet_address_space, 10, 0)] + location = var.region + create_nsg = true + network_security_group_name = "nsg-bastion-${var.env_config}-uks" + network_security_group_nsg_rules = local.bastion_nsg_rules + + log_analytics_workspace_id = module.log_analytics_workspace.id + monitor_diagnostic_setting_network_security_group_enabled_logs = ["NetworkSecurityGroupEvent", "NetworkSecurityGroupRuleCounter"] +} + +# Subnet for the gateway test VM +# cidrsubnet(vnet/16, 8, 1) → /24 +module "subnet_arc_servers" { + source = "../dtos-devops-templates/infrastructure/modules/subnet" + + name = "snet-arc-servers-${var.env_config}-uks" + resource_group_name = azurerm_resource_group.main.name + vnet_name = module.vnet.name + address_prefixes = [cidrsubnet(var.vnet_address_space, 8, 1)] + location = var.region + create_nsg = false + network_security_group_name = "nsg-arc-servers-${var.env_config}-uks" + + log_analytics_workspace_id = module.log_analytics_workspace.id + monitor_diagnostic_setting_network_security_group_enabled_logs = [] +} + +# NAT gateway for outbound internet access from the arc-servers subnet. +# Required because Azure no longer provides default outbound SNAT for new VMs. +# Arc agents need outbound HTTPS to Microsoft endpoints to register and report status. +module "nat_gateway" { + source = "../dtos-devops-templates/infrastructure/modules/nat-gateway" + + name = "nat-${var.app_short_name}-${var.env_config}-uks" + public_ip_name = "pip-nat-${var.app_short_name}-${var.env_config}-uks" + resource_group_name = azurerm_resource_group.main.name + location = var.region + subnet_id = module.subnet_arc_servers.id +} + +# Bastion host for Entra ID-authenticated RDP access to the test VM +module "bastion" { + source = "../dtos-devops-templates/infrastructure/modules/bastion" + + name = "bas-${var.app_short_name}-${var.env_config}-uks" + public_ip_name = "pip-bastion-${var.app_short_name}-${var.env_config}-uks" + resource_group_name = azurerm_resource_group.main.name + location = var.region + sku = var.bastion_sku + subnet_id = module.subnet_bastion.id + + log_analytics_workspace_id = module.log_analytics_workspace.id + monitor_diagnostic_setting_bastion_enabled_logs = ["BastionAuditLogs"] + monitor_diagnostic_setting_bastion_metrics = ["AllMetrics"] +} diff --git a/infrastructure/modules/gateway-test-vm/outputs.tf b/infrastructure/modules/gateway-test-vm/outputs.tf new file mode 100644 index 00000000..4e0429a1 --- /dev/null +++ b/infrastructure/modules/gateway-test-vm/outputs.tf @@ -0,0 +1,29 @@ +output "resource_group_name" { + description = "Name of the gateway test VM resource group" + value = azurerm_resource_group.main.name +} + +output "vnet_id" { + description = "ID of the gateway test VM VNet" + value = module.vnet.vnet.id +} + +output "arc_servers_subnet_id" { + description = "ID of the Arc servers subnet" + value = module.subnet_arc_servers.id +} + +output "log_analytics_workspace_id" { + description = "ID of the Log Analytics workspace" + value = module.log_analytics_workspace.id +} + +output "log_analytics_workspace_name" { + description = "Name of the Log Analytics workspace" + value = module.log_analytics_workspace.name +} + +output "gateway_test_vm_name" { + description = "Name of the gateway test VM" + value = module.gateway_test_vm.name +} diff --git a/infrastructure/modules/gateway-test-vm/providers.tf b/infrastructure/modules/gateway-test-vm/providers.tf new file mode 100644 index 00000000..4eba27a8 --- /dev/null +++ b/infrastructure/modules/gateway-test-vm/providers.tf @@ -0,0 +1,13 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "4.60.0" + configuration_aliases = [azurerm.hub] + } + azuread = { + source = "hashicorp/azuread" + version = "3.4.0" + } + } +} diff --git a/infrastructure/modules/gateway-test-vm/variables.tf b/infrastructure/modules/gateway-test-vm/variables.tf new file mode 100644 index 00000000..b6c5ad96 --- /dev/null +++ b/infrastructure/modules/gateway-test-vm/variables.tf @@ -0,0 +1,44 @@ +variable "region" { + description = "Azure region for all resources" + type = string +} + +variable "app_short_name" { + description = "Application short name used in resource naming (e.g., mbsgw)" + type = string +} + +variable "env_config" { + description = "Environment configuration name (e.g., dev, review)" + type = string +} + +variable "resource_group_name" { + description = "Name of the resource group to create for gateway test VM infrastructure" + type = string +} + +variable "vnet_address_space" { + description = "Address space for the VNet (e.g., 10.130.0.0/16)" + type = string +} + +variable "bastion_sku" { + description = "SKU tier for Azure Bastion (Basic or Standard)" + type = string +} + +variable "gateway_test_vm_size" { + description = "SKU size for the gateway test VM (e.g., Standard_B2s)" + type = string +} + +variable "arc_enabled_servers_resource_group" { + description = "Name of the Arc-enabled servers resource group (from arc-infra output) — used as Arc registration target in the onboarding script" + type = string +} + +variable "arc_onboarding_spn_client_id" { + description = "Client ID of the Arc onboarding service principal (from arc-infra output)" + type = string +} diff --git a/infrastructure/modules/gateway-test-vm/vm.tf b/infrastructure/modules/gateway-test-vm/vm.tf new file mode 100644 index 00000000..451a5294 --- /dev/null +++ b/infrastructure/modules/gateway-test-vm/vm.tf @@ -0,0 +1,151 @@ +data "azurerm_client_config" "current" {} + +# Infra Key Vault — created by Bicep in the hub subscription. +# Secrets managed manually: arc-onboarding-spn-client-secret, gateway-test-vm-admin-password. +data "azurerm_key_vault" "infra" { + provider = azurerm.hub + + name = "kv-${var.app_short_name}-${var.env_config}-inf" + resource_group_name = "rg-${var.app_short_name}-${var.env_config}-infra" +} + +data "azurerm_key_vault_secret" "arc_onboarding_spn_secret" { + provider = azurerm.hub + + name = "arc-onboarding-spn-client-secret" + key_vault_id = data.azurerm_key_vault.infra.id +} + +data "azurerm_key_vault_secret" "vm_admin_password" { + provider = azurerm.hub + + name = "gateway-test-vm-admin-password" + key_vault_id = data.azurerm_key_vault.infra.id +} + + +module "gateway_test_vm" { + source = "Azure/avm-res-compute-virtualmachine/azurerm" + version = "~> 0.20" + + name = "vm-${var.app_short_name}-${var.env_config}-uks" + resource_group_name = azurerm_resource_group.main.name + location = var.region + + os_type = "Windows" + sku_size = var.gateway_test_vm_size + zone = "1" + computer_name = "${var.app_short_name}-${var.env_config}" + + account_credentials = { + admin_credentials = { + username = "arcadmin" + password = data.azurerm_key_vault_secret.vm_admin_password.value + generate_admin_password_or_ssh_key = false + } + } + + # Required by NHSE CCoE policy "Private endpoints for Guest Configuration assignments should be enabled" + # (policy definition 480d0f91-30af-4a76-9afb-f5710ac52b09, effect Deny). + # Both spellings must be present — "Nework" is a typo in the policy definition itself. + tags = { + EnablePrivateNeworkGC = "TRUE" + EnablePrivateNetworkGC = "TRUE" + } + + source_image_reference = { + publisher = "MicrosoftWindowsServer" + offer = "WindowsServer" + sku = "2022-datacenter-azure-edition" + version = "latest" + } + + managed_identities = { + system_assigned = true + } + + network_interfaces = { + network_interface_01 = { + name = "nic-${var.app_short_name}-${var.env_config}-uks" + ip_configurations = { + ip_configuration_01 = { + name = "ipconfig1" + private_ip_subnet_resource_id = module.subnet_arc_servers.id + is_primary_ipconfiguration = true + } + } + } + } + + extensions = { + aad_login = { + name = "AADLoginForWindows" + publisher = "Microsoft.Azure.ActiveDirectory" + type = "AADLoginForWindows" + type_handler_version = "2.0" + auto_upgrade_minor_version = true + } + } +} + +# Runs Arc setup once at VM creation. ignore_changes prevents re-running on an already-enrolled VM. +resource "azurerm_virtual_machine_run_command" "arc_setup" { + name = "ArcSetup" + location = var.region + virtual_machine_id = module.gateway_test_vm.resource_id + + source { + script = file("${path.module}/../../../scripts/powershell/arc-setup.ps1") + } + + parameter { + name = "SubscriptionId" + value = data.azurerm_client_config.current.subscription_id + } + + parameter { + name = "TenantId" + value = data.azurerm_client_config.current.tenant_id + } + + parameter { + name = "ResourceGroup" + value = var.arc_enabled_servers_resource_group + } + + parameter { + name = "Location" + value = var.region + } + + parameter { + name = "ServicePrincipalId" + value = var.arc_onboarding_spn_client_id + } + + # Site identity tags — stamped onto the Arc resource for Terraform HC discovery + # and ADO pipeline ring targeting. ring0 = test VM only. + parameter { + name = "SiteCode" + value = "${var.app_short_name}-${var.env_config}" + } + + parameter { + name = "SiteType" + value = "static" + } + + parameter { + name = "DeploymentRing" + value = "ring0" + } + + protected_parameter { + name = "ServicePrincipalSecret" + value = data.azurerm_key_vault_secret.arc_onboarding_spn_secret.value + } + + lifecycle { + ignore_changes = [source, parameter, protected_parameter] + } +} diff --git a/infrastructure/terraform/main.tf b/infrastructure/terraform/main.tf new file mode 100644 index 00000000..ac3fbfc1 --- /dev/null +++ b/infrastructure/terraform/main.tf @@ -0,0 +1,40 @@ +module "arc_infra" { + source = "../modules/arc-infra" + + providers = { + azurerm = azurerm + azuread = azuread + } + + region = local.region + app_short_name = var.app_short_name + env_config = var.env_config + resource_group_name = local.resource_group_name + enable_arc_servers = var.enable_arc_servers + + # Create the HC for the test VM in the same run as VM creation. + # The Arc data source won't see a machine registered in the same apply. + static_arc_machine_names = var.enable_gateway_test_vm ? ["${var.app_short_name}-${var.env_config}"] : [] +} + +module "gateway_test_vm" { + count = var.enable_gateway_test_vm ? 1 : 0 + source = "../modules/gateway-test-vm" + + providers = { + azurerm = azurerm + azurerm.hub = azurerm.hub + azuread = azuread + } + + region = local.region + app_short_name = var.app_short_name + env_config = var.env_config + resource_group_name = local.resource_group_name + vnet_address_space = var.vnet_address_space + gateway_test_vm_size = var.gateway_test_vm_size + bastion_sku = var.bastion_sku + + arc_enabled_servers_resource_group = module.arc_infra.arc_enabled_servers_resource_group_name + arc_onboarding_spn_client_id = module.arc_infra.arc_onboarding_spn_client_id +} diff --git a/infrastructure/terraform/outputs.tf b/infrastructure/terraform/outputs.tf new file mode 100644 index 00000000..27b0703d --- /dev/null +++ b/infrastructure/terraform/outputs.tf @@ -0,0 +1,10 @@ +output "relay_namespace_hostname" { + description = "Relay namespace FQDN for AZURE_RELAY_NAMESPACE in the gateway .env" + value = module.arc_infra.relay_namespace_hostname +} + +output "relay_listen_sas_keys" { + description = "Per-machine relay listen SAS primary keys, keyed by Arc resource name (SiteCode)" + sensitive = true + value = module.arc_infra.relay_listen_sas_keys +} diff --git a/infrastructure/terraform/providers.tf b/infrastructure/terraform/providers.tf new file mode 100644 index 00000000..867f5de3 --- /dev/null +++ b/infrastructure/terraform/providers.tf @@ -0,0 +1,28 @@ +terraform { + required_providers { + azurerm = { + source = "hashicorp/azurerm" + version = "4.60.0" + } + azuread = { + source = "hashicorp/azuread" + version = "3.4.0" + } + } + backend "azurerm" { + container_name = "terraform-state" + } +} + +provider "azurerm" { + features {} +} + +provider "azurerm" { + alias = "hub" + subscription_id = var.hub_subscription_id + + features {} +} + +provider "azuread" {} diff --git a/infrastructure/terraform/resource_group_init/core.bicep b/infrastructure/terraform/resource_group_init/core.bicep index a0e22e4d..a2566858 100644 --- a/infrastructure/terraform/resource_group_init/core.bicep +++ b/infrastructure/terraform/resource_group_init/core.bicep @@ -18,6 +18,10 @@ var roleID = { storageBlobDataContributor: 'ba92f5b4-2d11-453d-a403-e96b0029c9fe' storageQueueDataContributor: '974c5e8b-45b9-4653-ba55-5f855dd0fb88' AzureConnectedMachineOnboarding: 'b64e21ea-ac4e-4cdf-9dc9-5b892992bee7' + AzureConnectedMachineResourceAdministrator: 'cd570a14-e51a-42ad-bac8-bafd67325302' + logAnalyticsContributor: '92aaf0da-9dab-42b6-94a3-d43ce8d16293' + resourcePolicyContributor: '36243c78-bf99-498c-9df9-ad4ef54afa4c' + virtualMachineAdministratorLogin: '1c0163c0-47e6-4577-8991-ea5c82e286e4' } // Define role assignments for managed identity @@ -32,13 +36,18 @@ var miRoleAssignments = [ roleId: roleID.kvSecretsUser description: 'kvSecretsUser access to subscription' } + { + roleName: 'resourcePolicyContributor' + roleId: roleID.resourcePolicyContributor + description: 'Resource Policy Contributor — required to create policy assignments for Azure Monitor' + } { roleName: 'rbacAdmin' roleId: roleID.rbacAdmin - description: 'RBAC Administrator. Restricted to only assign/remove: Storage Blob Data Contributor, Storage Queue Data Contributor, and Azure Connected Machine Onboarding.' + description: 'RBAC Administrator. Restricted to only assign/remove: Storage Blob Data Contributor, Storage Queue Data Contributor, Azure Connected Machine Onboarding, Azure Connected Machine Resource Administrator, Log Analytics Contributor, and Virtual Machine Administrator Login.' // Delegated RBAC: This condition restricts the RBAC Administrator to only manage specific roles. // This is a security best practice that prevents the identity from granting itself or others sensitive roles like 'Owner' or 'User Access Administrator'. - condition: '((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/write\'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}})) AND ((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/delete\'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}}))' + condition: '((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/write\'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}, ${roleID.AzureConnectedMachineResourceAdministrator}, ${roleID.logAnalyticsContributor}, ${roleID.virtualMachineAdministratorLogin}})) AND ((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/delete\'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}, ${roleID.AzureConnectedMachineResourceAdministrator}, ${roleID.logAnalyticsContributor}, ${roleID.virtualMachineAdministratorLogin}}))' conditionVersion: '2.0' } ] @@ -58,10 +67,10 @@ var groupRoleAssignments = [ { roleName: 'rbacAdmin' roleId: roleID.rbacAdmin - description: 'RBAC Administrator. Restricted to only assign/remove: Storage Blob Data Contributor, Storage Queue Data Contributor, and Azure Connected Machine Onboarding.' + description: 'RBAC Administrator. Restricted to only assign/remove: Storage Blob Data Contributor, Storage Queue Data Contributor, Azure Connected Machine Onboarding, Azure Connected Machine Resource Administrator, Log Analytics Contributor, and Virtual Machine Administrator Login.' // Delegated RBAC: This condition restricts the RBAC Administrator to only manage specific roles. // This is a security best practice that prevents the identity from granting itself or others sensitive roles like 'Owner' or 'User Access Administrator'. - condition: '((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/write\'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}})) AND ((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/delete\'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}}))' + condition: '((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/write\'})) OR (@Request[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}, ${roleID.AzureConnectedMachineResourceAdministrator}, ${roleID.logAnalyticsContributor}, ${roleID.virtualMachineAdministratorLogin}})) AND ((!(ActionMatches{\'Microsoft.Authorization/roleAssignments/delete\'})) OR (@Resource[Microsoft.Authorization/roleAssignments:RoleDefinitionId] ForAnyOfAnyValues:GuidEquals {${roleID.storageBlobDataContributor}, ${roleID.storageQueueDataContributor}, ${roleID.AzureConnectedMachineOnboarding}, ${roleID.AzureConnectedMachineResourceAdministrator}, ${roleID.logAnalyticsContributor}, ${roleID.virtualMachineAdministratorLogin}}))' conditionVersion: '2.0' } ] diff --git a/infrastructure/terraform/variables.tf b/infrastructure/terraform/variables.tf new file mode 100644 index 00000000..786de019 --- /dev/null +++ b/infrastructure/terraform/variables.tf @@ -0,0 +1,56 @@ +variable "app_short_name" { + description = "Application short name used in resource naming (e.g., mbsgw)" + type = string +} + +variable "environment" { + description = "Application environment name (e.g., dev, pr-123)" + type = string +} + +variable "env_config" { + description = "Environment configuration name shared across environments (e.g., dev, review)" + type = string +} + +variable "hub_subscription_id" { + # Used as the backend subscription_id in terraform-init and by the azurerm.hub provider. + # Passed via TF_VAR_hub_subscription_id in terraform-init. + description = "ID of the hub Azure subscription (Terraform state storage and infra Key Vault)" + type = string +} + +variable "enable_arc_servers" { + description = "Whether to create Arc-enabled server resource groups and role assignments" + type = bool + default = true +} + +variable "enable_gateway_test_vm" { + description = "Whether to deploy the gateway test VM environment (VNet, Bastion, NAT GW, Log Analytics, Windows VM)" + type = bool + default = false +} + +variable "vnet_address_space" { + description = "Address space for the gateway test VM VNet (e.g., 10.130.0.0/16). Required when enable_gateway_test_vm is true." + type = string + default = null +} + +variable "bastion_sku" { + description = "SKU tier for Azure Bastion (Basic or Standard)" + type = string + default = "Standard" +} + +variable "gateway_test_vm_size" { + description = "SKU size for the gateway test VM" + type = string + default = "Standard_B2s" +} + +locals { + region = "uksouth" + resource_group_name = "rg-${var.app_short_name}-${var.environment}-uks" +} diff --git a/scripts/bash/deploy_arc_ring.sh b/scripts/bash/deploy_arc_ring.sh new file mode 100755 index 00000000..dcb213d3 --- /dev/null +++ b/scripts/bash/deploy_arc_ring.sh @@ -0,0 +1,183 @@ +#!/bin/bash +# Deploy the gateway app to all Arc machines matching a ring within an environment. +# Called by deploy_stage.sh. +# +# Usage: deploy_arc_ring.sh + +set -euo pipefail + +ENVIRONMENT=$1 +RING=$2 +RELEASE_TAG=$3 +KV_NAME=$4 + +APP_SHORT_NAME="mbsgw" +ARC_RG="rg-${APP_SHORT_NAME}-${ENVIRONMENT}-uks-arc-enabled-servers" + +# Relay namespace is owned by dtos-manage-breast-screening; derive from environment name. +RELAY_NAMESPACE_NAME="relay-manbrs-${ENVIRONMENT}" +RELAY_RG="rg-manbrs-${ENVIRONMENT}-uks" +RELAY_NAMESPACE_HOSTNAME="${RELAY_NAMESPACE_NAME}.servicebus.windows.net" + +# Ensure the relay extension is installed +if ! az relay --help &>/dev/null; then + echo "Installing Azure CLI 'relay' extension..." + az extension add --name relay || { + echo "ERROR: Failed to install 'relay' extension. Please run 'az extension add --name relay' manually." + exit 1 + } +fi + +# Use forward slashes — Python handles these fine on Windows and avoids .env escaping issues +BASE_PATH="C:/Program Files/NHS/ManageBreastScreeningGateway" +PYTHON_VERSION=$(awk '/^python / {print $2}' .tool-versions) + +echo "--- Ring: ${RING} | Environment: ${ENVIRONMENT} | Release: ${RELEASE_TAG} ---" + +# ── Discover machines ────────────────────────────────────────────────────────── +MACHINES_JSON=$(az connectedmachine list \ + --resource-group "$ARC_RG" \ + --query "[?tags.DeploymentRing=='${RING}'].{name:name,location:location}" \ + --output json) + +MACHINE_COUNT=$(echo "$MACHINES_JSON" | jq 'length') +if [[ "$MACHINE_COUNT" -eq 0 ]]; then + echo "##vso[task.logissue type=warning]No machines found for ${RING} in ${ENVIRONMENT} — skipping" + exit 0 +fi + +echo "Found ${MACHINE_COUNT} machine(s) for ${RING} in ${ENVIRONMENT}" + +SUB_ID=$(az account show --query id -o tsv) +GITHUB_TOKEN="${GITHUB_TOKEN:-}" + +# ── Submit all Run Commands, then wait in parallel ───────────────────────────── +# Arrays to track per-machine state +declare -a MACHINE_NAMES=() +declare -a RUN_CMD_NAMES=() + +while IFS= read -r MACHINE_JSON; do + MACHINE=$(echo "$MACHINE_JSON" | jq -r '.name') + LOCATION=$(echo "$MACHINE_JSON" | jq -r '.location') + echo "Preparing deploy for $MACHINE ($LOCATION)..." + + # Fetch relay SAS key directly — Contributor includes listKeys on relay HCs, + # and this avoids any dependency on Terraform state having the resource imported. + echo "Fetching SAS key for hc-${MACHINE} in $RELAY_NAMESPACE_NAME..." + SAS_KEY=$(az relay hyco authorization-rule keys list \ + --resource-group "$RELAY_RG" \ + --namespace-name "$RELAY_NAMESPACE_NAME" \ + --hybrid-connection-name "hc-${MACHINE}" \ + --name listen \ + --query primaryKey -o tsv 2>/tmp/relay_key_err_${MACHINE}) || { + ERR=$(cat /tmp/relay_key_err_${MACHINE}) + echo "##vso[task.logissue type=warning]Failed to fetch relay SAS key for hc-${MACHINE}: $ERR" + SAS_KEY="" + } + + [[ -z "$SAS_KEY" ]] && \ + echo "##vso[task.logissue type=warning]No relay SAS key found for hc-${MACHINE} — relay listener will not connect" + + # Cloud API secrets are optional — warn if absent, services still start + CLOUD_API_ENDPOINT=$(az keyvault secret show --vault-name "$KV_NAME" \ + --name "cloud-api-endpoint" --query value -o tsv 2>/dev/null || echo "") + CLOUD_API_TOKEN=$(az keyvault secret show --vault-name "$KV_NAME" \ + --name "cloud-api-token-${MACHINE}" --query value -o tsv 2>/dev/null || echo "") + + [[ -z "$CLOUD_API_ENDPOINT" ]] && \ + echo "##vso[task.logissue type=warning]cloud-api-endpoint not in $KV_NAME — Upload service will not reach cloud API for $MACHINE" + [[ -z "$CLOUD_API_TOKEN" ]] && \ + echo "##vso[task.logissue type=warning]cloud-api-token-${MACHINE} not in $KV_NAME — Upload service will not authenticate for $MACHINE" + + # Build .env, then base64-encode to pass newlines as a run command parameter. + # NOTE: Arc Run Command drops protectedParameters for inline source.script, + # so EnvContentB64 travels as a regular parameter (base64-encoded, not plain text). + # TODO: migrate to Key Vault + Arc MSI for production environments. + ENV_CONTENT="AZURE_RELAY_NAMESPACE=${RELAY_NAMESPACE_HOSTNAME} +AZURE_RELAY_HYBRID_CONNECTION=hc-${MACHINE} +AZURE_RELAY_KEY_NAME=listen +AZURE_RELAY_SHARED_ACCESS_KEY=${SAS_KEY} +CLOUD_API_ENDPOINT=${CLOUD_API_ENDPOINT} +CLOUD_API_TOKEN=${CLOUD_API_TOKEN} +MWL_AET=SCREENING_MWL +MWL_PORT=4243 +MWL_DB_PATH=${BASE_PATH}/data/worklist.db +PACS_AET=SCREENING_PACS +PACS_PORT=4244 +PACS_STORAGE_PATH=${BASE_PATH}/data/storage +PACS_DB_PATH=${BASE_PATH}/data/pacs.db +LOG_LEVEL=INFO" + + # Cross-platform base64 encoding (works on macOS and Linux) + ENV_CONTENT_B64=$(printf '%s' "$ENV_CONTENT" | base64 | tr -d '\n') + + # deploy.ps1 is embedded directly in source.script (limit ~4 MB) rather than passed + # as a parameter value. Parameter values are passed on the PowerShell command line and + # the Windows command line limit (32,767 chars) would be exceeded by the ~40 KB script. + + # Use machine name + timestamp to ensure uniqueness across parallel submissions. + CLEAN_TAG=$(echo "${RELEASE_TAG}" | tr '.' '-' | tr '/' '-') + DEPLOY_ID=$(date +%s) + RUN_CMD_NAME="deploy-mbsgw-${CLEAN_TAG}-${MACHINE}-${DEPLOY_ID}" + + CMD_URL="https://management.azure.com/subscriptions/${SUB_ID}/resourceGroups/${ARC_RG}/providers/Microsoft.HybridCompute/machines/${MACHINE}/runCommands/${RUN_CMD_NAME}?api-version=2024-07-10" + + BODY=$(jq -n \ + --arg loc "$LOCATION" \ + --rawfile script scripts/powershell/deploy.ps1 \ + --arg tag "$RELEASE_TAG" \ + --arg pyver "$PYTHON_VERSION" \ + --arg envb64 "$ENV_CONTENT_B64" \ + --arg token "$GITHUB_TOKEN" \ + '{ + location: $loc, + properties: { + source: { script: $script }, + parameters: [ + { name: "ReleaseTag", value: $tag }, + { name: "PythonVersion", value: $pyver }, + { name: "EnvContentB64", value: $envb64 }, + { name: "GitHubToken", value: $token } + ], + runAsSystem: true, + timeoutInSeconds: 1800 + } + }') + + echo "Submitting run command '$RUN_CMD_NAME' for $MACHINE..." + PUT_RESPONSE=$(az rest --method PUT \ + --url "$CMD_URL" \ + --body "$BODY" \ + --output json) + + PROV_STATE=$(echo "$PUT_RESPONSE" | jq -r '.properties.provisioningState // "unknown"') + echo "Run command submitted for $MACHINE: $PROV_STATE" + + MACHINE_NAMES+=("$MACHINE") + RUN_CMD_NAMES+=("$RUN_CMD_NAME") + +done < <(echo "$MACHINES_JSON" | jq -c '.[]') + +# ── Wait for all machines in parallel ───────────────────────────────────────── +echo "Waiting for ${#MACHINE_NAMES[@]} machine(s) in parallel..." + +declare -a PIDS=() +for i in "${!MACHINE_NAMES[@]}"; do + MACHINE="${MACHINE_NAMES[$i]}" + RUN_CMD_NAME="${RUN_CMD_NAMES[$i]}" + ( + scripts/bash/wait_arc_run_command.sh "$MACHINE" "$ARC_RG" "$RUN_CMD_NAME" \ + && echo "Deploy succeeded for $MACHINE" \ + || { echo "ERROR: Deploy failed for $MACHINE"; exit 1; } + ) & + PIDS+=($!) +done + +FAILED=0 +for i in "${!PIDS[@]}"; do + if ! wait "${PIDS[$i]}"; then + FAILED=1 + fi +done + +exit $FAILED diff --git a/scripts/bash/deploy_stage.sh b/scripts/bash/deploy_stage.sh new file mode 100755 index 00000000..807f4ced --- /dev/null +++ b/scripts/bash/deploy_stage.sh @@ -0,0 +1,26 @@ +#!/bin/bash +# Deploy the gateway app to each ring in sequence. +# A ring with no machines is skipped gracefully. +# Any machine failure fails the ring and stops further rings. +# +# Usage: deploy_stage.sh "" +# Env: ARM_TENANT_ID, ARM_CLIENT_ID, ARM_OIDC_TOKEN, ARM_USE_OIDC (set by AzureCLI@2 task) + +set -euo pipefail + +ENVIRONMENT=$1 +RINGS=$2 +RELEASE_TAG=$3 + +APP_SHORT_NAME="mbsgw" +KV_NAME="kv-${APP_SHORT_NAME}-${ENVIRONMENT}-inf" + +echo "========================================" +echo "Environment : ${ENVIRONMENT}" +echo "Rings : ${RINGS}" +echo "Release : ${RELEASE_TAG}" +echo "========================================" + +for RING in $RINGS; do + scripts/bash/deploy_arc_ring.sh "$ENVIRONMENT" "$RING" "$RELEASE_TAG" "$KV_NAME" +done diff --git a/scripts/bash/wait_ado_pipeline.sh b/scripts/bash/wait_ado_pipeline.sh new file mode 100755 index 00000000..f0a9f22e --- /dev/null +++ b/scripts/bash/wait_ado_pipeline.sh @@ -0,0 +1,44 @@ +#!/bin/bash + +set -euo pipefail + +RUN_ID="$1" +ORG_URL="$2" +PROJECT="$3" + +SLEEP_TIME=15 +TIMEOUT_SECONDS=3600 + +echo "Waiting for Azure DevOps pipeline run $RUN_ID to complete..." + +START_TIME=$(date +%s) + +while true; do + PIPELINE_JSON=$(az pipelines runs show \ + --id "$RUN_ID" \ + --org "$ORG_URL" \ + --project "$PROJECT" \ + --output json) + STATUS=$(echo "$PIPELINE_JSON" | jq -r '.status') + RESULT=$(echo "$PIPELINE_JSON" | jq -r '.result') + + if [[ "$STATUS" == "completed" ]]; then + if [[ "$RESULT" == "succeeded" ]]; then + echo "Status: $STATUS. Pipeline run $RUN_ID succeeded." + exit 0 + else + echo "Status: $STATUS. Pipeline run $RUN_ID failed with result: $RESULT" + exit 1 + fi + fi + + CURRENT_TIME=$(date +%s) + ELAPSED=$((CURRENT_TIME - START_TIME)) + if (( ELAPSED > TIMEOUT_SECONDS )); then + echo "ERROR: Timeout of ${TIMEOUT_SECONDS}s reached while waiting for pipeline run $RUN_ID." + exit 2 + fi + + echo "Status: $STATUS (Elapsed: ${ELAPSED}s)" + sleep "$SLEEP_TIME" +done diff --git a/scripts/bash/wait_arc_run_command.sh b/scripts/bash/wait_arc_run_command.sh new file mode 100755 index 00000000..de3985d2 --- /dev/null +++ b/scripts/bash/wait_arc_run_command.sh @@ -0,0 +1,86 @@ +#!/bin/bash +# Poll an Arc Run Command until it completes, printing output on completion. +# Usage: wait_arc_run_command.sh +# +# Uses az rest GET (same API as the PUT submission) to avoid CLI extension +# version issues and to surface HTTP errors rather than suppressing them. +# +# provisioningState: Succeeded only means ARM accepted the resource. +# instanceView.executionState carries the actual script execution status. + +set -euo pipefail + +MACHINE=$1 +RG=$2 +CMD_NAME=$3 + +SLEEP_TIME=20 +TIMEOUT_SECONDS=1800 + +SUB_ID=$(az account show --query id -o tsv) +CMD_URL="https://management.azure.com/subscriptions/${SUB_ID}/resourceGroups/${RG}/providers/Microsoft.HybridCompute/machines/${MACHINE}/runCommands/${CMD_NAME}?api-version=2024-07-10" + +echo "Waiting for Arc Run Command '$CMD_NAME' on '$MACHINE'..." + +START_TIME=$(date +%s) + +while true; do + # Capture stderr separately so a transient 404 (ARM propagation delay) doesn't + # kill the script under set -euo pipefail. + CMD_JSON=$(az rest --method GET --url "$CMD_URL" --output json 2>/tmp/arc_wait_err) || { + ERR=$(cat /tmp/arc_wait_err) + if echo "$ERR" | grep -q "404\|Not Found"; then + echo "Run command not yet visible in ARM — retrying in ${SLEEP_TIME}s..." + sleep "$SLEEP_TIME" + continue + fi + echo "ERROR polling run command: $ERR" + exit 1 + } + + PROVISIONING_STATE=$(echo "$CMD_JSON" | jq -r '.properties.provisioningState // "Unknown"') + EXEC_STATE=$(echo "$CMD_JSON" | jq -r '.properties.instanceView.executionState // "Unknown"') + + CURRENT_TIME=$(date +%s) + ELAPSED=$((CURRENT_TIME - START_TIME)) + + # Terminal conditions: + # - ARM itself failed/canceled (script will never run) + # - OR the script execution reached a terminal state + TERMINAL=false + if [[ "$PROVISIONING_STATE" == "Failed" || "$PROVISIONING_STATE" == "Canceled" ]]; then + TERMINAL=true + elif [[ "$EXEC_STATE" == "Succeeded" || "$EXEC_STATE" == "Failed" \ + || "$EXEC_STATE" == "TimedOut" || "$EXEC_STATE" == "Canceled" ]]; then + TERMINAL=true + fi + + if $TERMINAL; then + EXIT_CODE=$(echo "$CMD_JSON" | jq -r '.properties.instanceView.exitCode // -1') + OUTPUT=$(echo "$CMD_JSON" | jq -r '.properties.instanceView.output // ""') + ERROR_OUT=$(echo "$CMD_JSON" | jq -r '.properties.instanceView.error // ""') + + [[ -n "$OUTPUT" ]] && echo "=== Script output ===" && echo "$OUTPUT" + [[ -n "$ERROR_OUT" ]] && echo "=== Script error ===" && echo "$ERROR_OUT" + + if [[ "$EXEC_STATE" == "Succeeded" && "$EXIT_CODE" == "0" ]]; then + echo "Arc Run Command '$CMD_NAME' on '$MACHINE' succeeded." + exit 0 + else + echo "Arc Run Command '$CMD_NAME' on '$MACHINE' failed: provisioningState=$PROVISIONING_STATE, executionState=$EXEC_STATE, exitCode=$EXIT_CODE" + echo "=== Full instanceView ===" + echo "$CMD_JSON" | jq '.properties.instanceView' + exit 1 + fi + fi + + if (( ELAPSED > TIMEOUT_SECONDS )); then + echo "ERROR: Timeout (${TIMEOUT_SECONDS}s) waiting for '$CMD_NAME' on '$MACHINE'" + echo "=== Full instanceView at timeout ===" + echo "$CMD_JSON" | jq '.properties.instanceView' + exit 2 + fi + + echo "State: provisioning=$PROVISIONING_STATE execution=$EXEC_STATE (${ELAPSED}s elapsed)" + sleep "$SLEEP_TIME" +done diff --git a/scripts/powershell/arc-setup.ps1 b/scripts/powershell/arc-setup.ps1 new file mode 100644 index 00000000..d2b229c1 --- /dev/null +++ b/scripts/powershell/arc-setup.ps1 @@ -0,0 +1,236 @@ +# Azure Arc evaluation and onboarding script. +# Parameters injected by Terraform via azurerm_virtual_machine_run_command. +param( + [string]$SubscriptionId, + [string]$TenantId, + [string]$ResourceGroup, + [string]$Location, + [string]$ServicePrincipalId, + [string]$ServicePrincipalSecret, + # Site identity - controls the Arc resource name and Azure tags. + # SiteCode becomes the Arc machine name in Azure (e.g. gw-RVJ-01). + # Defaults to the machine hostname when not supplied (test/dev use). + [string]$SiteCode = "", # e.g. gw-RVJ-01 (ODS code + instance) + [string]$SiteName = "", # e.g. North-Bristol-NHS-Trust (no spaces) + [string]$NHSRegion = "", # nw|neyh|mids|eoe|lon|se|sw + [string]$PacsVendor = "", # sectra|fujifilm|agfa|philips|carestream + [string]$SiteType = "static", # static|mobile + [string]$DeploymentRing = "ring0" # ring0|ring1|ring2|ring3|ring4 +) + +$ErrorActionPreference = 'Stop' +$logFile = "C:\ArcSetup\ArcSetup.log" + +New-Item -Path "C:\ArcSetup" -ItemType Directory -Force | Out-Null + +function Write-Log { + param([string]$Message, [string]$Level = 'INFO') + $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + $logMessage = "[$timestamp] [$Level] $Message" + Add-Content -Path $logFile -Value $logMessage -Force + Write-Output $logMessage +} + +try { + Write-Log "=========================================" "INFO" + Write-Log "Azure Arc Combined Setup Started" "INFO" + Write-Log "=========================================" "INFO" + Write-Log "SiteCode : $(if ($SiteCode) { $SiteCode } else { '(hostname)' })" "INFO" + Write-Log "SiteName : $(if ($SiteName) { $SiteName } else { '(not set)' })" "INFO" + Write-Log "NHSRegion : $(if ($NHSRegion) { $NHSRegion } else { '(not set)' })" "INFO" + Write-Log "PacsVendor : $(if ($PacsVendor) { $PacsVendor } else { '(not set)' })" "INFO" + Write-Log "SiteType : $SiteType" "INFO" + Write-Log "DeploymentRing : $DeploymentRing" "INFO" + Write-Log "=========================================" "INFO" + + $CorrelationId = [guid]::NewGuid().ToString() + $ImdsEndpoint = '169.254.169.254' # gitleaks:allow + + $env:SUBSCRIPTION_ID = $SubscriptionId + $env:RESOURCE_GROUP = $ResourceGroup + $env:TENANT_ID = $TenantId + $env:LOCATION = $Location + $env:AUTH_TYPE = "principal" + $env:CORRELATION_ID = $CorrelationId + $env:CLOUD = "AzureCloud" + + # ============================================ + # PHASE 1: Azure Arc Evaluation Preparation + # ============================================ + Write-Log "PHASE 1: Preparing VM for Azure Arc Evaluation" "INFO" + Write-Log "-------------------------------------------" "INFO" + + Write-Log "STEP 1: Setting MSFT_ARC_TEST environment variable..." "INFO" + [System.Environment]::SetEnvironmentVariable('MSFT_ARC_TEST', 'true', [System.EnvironmentVariableTarget]::Machine) + Write-Log "MSFT_ARC_TEST set to 'true'" "SUCCESS" + + Write-Log "STEP 2: Disabling Azure VM Guest Agent..." "INFO" + $service = Get-Service -Name 'WindowsAzureGuestAgent' -ErrorAction SilentlyContinue + if ($service) { + Set-Service WindowsAzureGuestAgent -StartupType Disabled -Verbose + Stop-Service WindowsAzureGuestAgent -Force -Verbose + Get-Process -Name 'WindowsAzureGuestAgent', 'WaAppAgent' -ErrorAction SilentlyContinue | Stop-Process -Force + Start-Sleep -Seconds 3 + $serviceStatus = (Get-Service -Name 'WindowsAzureGuestAgent').Status + Write-Log "Azure Guest Agent status: $serviceStatus" "SUCCESS" + } else { + Write-Log "WindowsAzureGuestAgent service not found" "WARNING" + } + + Write-Log "STEP 3: Blocking Azure IMDS endpoint ($ImdsEndpoint)..." "INFO" + $existingRule = Get-NetFirewallRule -Name 'BlockAzureIMDS' -ErrorAction SilentlyContinue + if ($existingRule) { + Remove-NetFirewallRule -Name 'BlockAzureIMDS' -Confirm:$false + } + New-NetFirewallRule -Name BlockAzureIMDS ` + -DisplayName "Block access to Azure IMDS" ` + -Enabled True ` + -Profile Any ` + -Direction Outbound ` + -Action Block ` + -RemoteAddress $ImdsEndpoint | Out-Null + Write-Log "Azure IMDS endpoint blocked" "SUCCESS" + + Write-Log "STEP 4: Verifying Arc Evaluation readiness..." "INFO" + try { + $null = Invoke-RestMethod -Headers @{'Metadata' = 'true' } -Method GET ` + -Uri "http://$ImdsEndpoint/metadata/instance?api-version=2021-02-01" -TimeoutSec 5 -ErrorAction Stop + Write-Log "WARNING: IMDS is still accessible - Arc onboarding may fail." "WARNING" + } catch { + Write-Log "IMDS is blocked as expected" "SUCCESS" + } + + Write-Log "Phase 1 completed successfully" "SUCCESS" + + # ============================================ + # PHASE 2: Azure Arc Agent Installation + # ============================================ + Write-Log "PHASE 2: Installing Azure Arc Agent" "INFO" + Write-Log "-------------------------------------------" "INFO" + + [Net.ServicePointManager]::SecurityProtocol = [Net.ServicePointManager]::SecurityProtocol -bor 3072 + + $azcmagentPath = Join-Path $env:SystemRoot "AzureConnectedMachineAgent" + $tempPath = Join-Path $azcmagentPath "temp" + New-Item -Path $azcmagentPath -ItemType Directory -Force | Out-Null + New-Item -Path $tempPath -ItemType Directory -Force | Out-Null + + $installScriptPath = Join-Path $tempPath "install_windows_azcmagent.ps1" + Write-Log "Downloading Azure Connected Machine Agent installer..." "INFO" + Invoke-WebRequest -UseBasicParsing ` + -Uri "https://gbl.his.arc.azure.com/azcmagent-windows" ` + -TimeoutSec 30 -OutFile "$installScriptPath" + Write-Log "Installer downloaded" "SUCCESS" + + Write-Log "Installing Azure Connected Machine Agent..." "INFO" + & "$installScriptPath" + if ($LASTEXITCODE -ne 0) { throw "Installation failed with exit code $LASTEXITCODE" } + Write-Log "Arc agent installed" "SUCCESS" + + Start-Sleep -Seconds 5 + + # ============================================ + # PHASE 3: Azure Arc Agent Connection + # ============================================ + Write-Log "PHASE 3: Connecting to Azure Arc" "INFO" + Write-Log "-------------------------------------------" "INFO" + + $azcmagentExe = "$env:ProgramW6432\AzureConnectedMachineAgent\azcmagent.exe" + if (-Not (Test-Path $azcmagentExe)) { throw "azcmagent.exe not found at $azcmagentExe" } + + Write-Log "Subscription : $SubscriptionId" "INFO" + Write-Log "Resource Group: $ResourceGroup" "INFO" + Write-Log "Location : $Location" "INFO" + + # Build tags - all site metadata is stamped onto the Arc resource for Terraform + # discovery and ADO pipeline targeting. SiteName must not contain spaces. + $tags = "ArcSQLServerExtensionDeployment=Disabled" + $tags += ",Programme=BreastScreening" + $tags += ",SiteType=$SiteType" + $tags += ",DeploymentRing=$DeploymentRing" + if ($SiteCode) { $tags += ",SiteCode=$SiteCode" } + if ($SiteName) { $tags += ",SiteName=$SiteName" } + if ($NHSRegion) { $tags += ",NHSRegion=$NHSRegion" } + if ($PacsVendor) { $tags += ",PacsVendor=$PacsVendor" } + + # Build connect arguments. --resource-name sets the Azure resource name, + # overriding the default (hostname). Required for meaningful HC naming in Terraform. + $connectArgs = @( + 'connect', + '--service-principal-id', $ServicePrincipalId, + '--service-principal-secret', $ServicePrincipalSecret, + '--resource-group', $ResourceGroup, + '--tenant-id', $TenantId, + '--location', $Location, + '--subscription-id', $SubscriptionId, + '--cloud', $env:CLOUD, + '--correlation-id', $CorrelationId, + '--tags', $tags + ) + if ($SiteCode) { $connectArgs += @('--resource-name', $SiteCode) } + + & "$azcmagentExe" @connectArgs + + if ($LASTEXITCODE -ne 0) { throw "Connection failed with exit code $LASTEXITCODE" } + Write-Log "Successfully connected to Azure Arc!" "SUCCESS" + + # ============================================ + # PHASE 4: Verification + # ============================================ + Write-Log "PHASE 4: Final Verification" "INFO" + Write-Log "-------------------------------------------" "INFO" + + try { + & "$azcmagentExe" show + Write-Log "Arc agent status retrieved" "SUCCESS" + } catch { + Write-Log "Could not retrieve Arc agent status: $($_.Exception.Message)" "WARNING" + } + + try { + Unregister-ScheduledTask -TaskName "ArcSetup" -Confirm:$false -ErrorAction SilentlyContinue + Write-Log "Scheduled task removed" "SUCCESS" + } catch { + Write-Log "Could not remove scheduled task: $($_.Exception.Message)" "WARNING" + } + + # Re-enable Guest Agent so Run Command can report status back to Azure. + # Arc evaluation posture is preserved: MSFT_ARC_TEST and IMDS firewall block persist. + Write-Log "Re-enabling Azure VM Guest Agent for Run Command status reporting..." "INFO" + Set-Service WindowsAzureGuestAgent -StartupType Automatic -ErrorAction SilentlyContinue + Start-Service WindowsAzureGuestAgent -ErrorAction SilentlyContinue + Start-Sleep -Seconds 20 + Write-Log "Azure VM Guest Agent re-enabled" "SUCCESS" + + Write-Log "=========================================" "SUCCESS" + Write-Log "Azure Arc Setup Completed Successfully!" "SUCCESS" + Write-Log "=========================================" "SUCCESS" + exit 0 + +} catch { + Write-Log "=========================================" "ERROR" + Write-Log "Azure Arc Setup Failed" "ERROR" + Write-Log "=========================================" "ERROR" + Write-Log "Error: $($_.Exception.Message)" "ERROR" + Write-Log "Stack Trace: $($_.ScriptStackTrace)" "ERROR" + + $logBody = @{ + subscriptionId = "$env:SUBSCRIPTION_ID" + resourceGroup = "$env:RESOURCE_GROUP" + tenantId = "$env:TENANT_ID" + location = "$env:LOCATION" + correlationId = "$env:CORRELATION_ID" + authType = "$env:AUTH_TYPE" + operation = "onboarding" + messageType = $_.FullyQualifiedErrorId + message = "$_" + } + try { + Invoke-WebRequest -UseBasicParsing -Uri "https://gbl.his.arc.azure.com/log" ` + -Method "PUT" -Body ($logBody | ConvertTo-Json) | Out-Null + } catch { + Write-Log "Failed to send error log: $($_.Exception.Message)" "WARNING" + } + + exit 1 +} diff --git a/scripts/powershell/cleanup.ps1 b/scripts/powershell/cleanup.ps1 index d631f2d7..a53884d9 100644 --- a/scripts/powershell/cleanup.ps1 +++ b/scripts/powershell/cleanup.ps1 @@ -33,7 +33,7 @@ foreach ($pattern in $ServicePatterns) { $services = Get-Service -Name $pattern -ErrorAction SilentlyContinue foreach ($svc in $services) { Write-Host "Processing Service: $($svc.Name)" -ForegroundColor Yellow - + if ($svc.Status -eq 'Running') { Write-Host " Stopping service..." -ForegroundColor Gray Stop-Service -Name $svc.Name -Force -ErrorAction SilentlyContinue @@ -61,7 +61,7 @@ foreach ($path in $Paths) { Write-Host " Removing junction..." -ForegroundColor Gray Remove-Item -Path $current -Force -ErrorAction SilentlyContinue } - + try { Remove-Item -Path $path -Recurse -Force Write-Host " Directory removed successfully." -ForegroundColor Green diff --git a/scripts/powershell/deploy.ps1 b/scripts/powershell/deploy.ps1 index 2d4ddb49..5c41a552 100644 --- a/scripts/powershell/deploy.ps1 +++ b/scripts/powershell/deploy.ps1 @@ -16,7 +16,7 @@ param( [string]$BaseInstallPath = "C:\Program Files\NHS\ManageBreastScreeningGateway", [Parameter()] - [switch]$Bootstrap, + [bool]$Bootstrap = $true, [Parameter()] [int]$KeepReleases = 3, @@ -40,7 +40,10 @@ param( [string]$ReleaseTag = "latest", [Parameter()] - [string]$GitHubToken + [string]$GitHubToken, + + [Parameter()] + [string]$EnvContentB64 = "" ) Set-StrictMode -Version Latest @@ -67,6 +70,16 @@ function Write-Log { } } +# -- Write .env (when deployed via Arc Run Command) --------------------------- + +if ($EnvContentB64) { + Write-Log "Writing .env from deployment parameter..." "INFO" + $envBytes = [System.Convert]::FromBase64String($EnvContentB64) + $envContent = [System.Text.Encoding]::UTF8.GetString($envBytes) + [System.IO.File]::WriteAllText((Join-Path $BaseInstallPath ".env"), $envContent, (New-Object System.Text.UTF8Encoding $false)) + Write-Log "Written .env to $BaseInstallPath" "SUCCESS" +} + # -- Helpers ------------------------------------------------------------------ function Invoke-Nssm { @@ -77,65 +90,6 @@ function Invoke-Nssm { } } -function Get-PythonVersionFromPyproject { - param([string]$PyprojectPath) - if (-not (Test-Path $PyprojectPath)) { return $null } - $content = Get-Content $PyprojectPath -Raw - if ($content -match 'requires-python\s*=\s*">=(\d+\.\d+)') { return $Matches[1] } - return $null -} - -function Get-PythonVersionFromZip { - param([string]$ArchivePath) - if (-not $ArchivePath -or -not (Test-Path $ArchivePath)) { return $null } - Add-Type -Assembly System.IO.Compression.FileSystem - $zip = $null - try { - $zip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) - $entry = $zip.Entries | Where-Object { $_.Name -eq "pyproject.toml" } | Select-Object -First 1 - - if ($entry) { - $reader = New-Object System.IO.StreamReader($entry.Open()) - $content = $reader.ReadToEnd() - $reader.Close() - if ($content -match 'requires-python\s*=\s*">=(\d+\.\d+)') { return $Matches[1] } - } - - # Check for inner ZIP (wrapper package from CI artifacts) - $innerZipEntry = $zip.Entries | Where-Object { $_.FullName -like "*.zip" } | Select-Object -First 1 - if ($innerZipEntry) { - $zip.Dispose(); $zip = $null - $tempInner = Join-Path $env:TEMP "deploy-pyver-$([guid]::NewGuid().ToString().Substring(0,8)).zip" - try { - $outerZip = [System.IO.Compression.ZipFile]::OpenRead($ArchivePath) - $innerEntry = $outerZip.Entries | Where-Object { $_.FullName -eq $innerZipEntry.FullName } | Select-Object -First 1 - [System.IO.Compression.ZipFileExtensions]::ExtractToFile($innerEntry, $tempInner, $true) - $outerZip.Dispose() - - $innerZip = [System.IO.Compression.ZipFile]::OpenRead($tempInner) - $innerPyproject = $innerZip.Entries | Where-Object { $_.Name -eq "pyproject.toml" } | Select-Object -First 1 - if ($innerPyproject) { - $reader = New-Object System.IO.StreamReader($innerPyproject.Open()) - $content = $reader.ReadToEnd() - $reader.Close() - if ($content -match 'requires-python\s*=\s*">=(\d+\.\d+)') { - $innerZip.Dispose() - return $Matches[1] - } - } - $innerZip.Dispose() - } finally { - if (Test-Path $tempInner) { Remove-Item $tempInner -Force -ErrorAction SilentlyContinue } - } - } - } catch { - Write-Log "Could not read Python version from archive: $_" "WARNING" - return $null - } finally { - if ($zip) { $zip.Dispose() } - } - return $null -} function Stop-AllServices { param([array]$Services, [int]$TimeoutSeconds) @@ -167,42 +121,82 @@ function Stop-AllServices { if ($Bootstrap) { Write-Log "Bootstrapping system environment..." "INFO" + function Install-WithRetry { + param([string]$Name, [scriptblock]$Script, [int]$MaxAttempts = 3) + $attempt = 0 + while ($attempt -lt $MaxAttempts) { + $attempt++ + try { + Write-Log "Installing $Name (attempt $attempt/$MaxAttempts)..." "INFO" + & $Script + return + } catch { + Write-Log "Failed to install ${Name}: $_" "WARNING" + if ($attempt -lt $MaxAttempts) { + $wait = 10 * $attempt + Write-Log "Waiting ${wait}s before retry..." "INFO" + Start-Sleep -Seconds $wait + } + } + } + throw "Failed to install $Name after $MaxAttempts attempts." + } + if (-not (Get-Command choco.exe -ErrorAction SilentlyContinue)) { - Write-Log "Installing Chocolatey..." "INFO" - Set-ExecutionPolicy Bypass -Scope Process -Force - [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 - Invoke-Expression ((New-Object System.Net.WebClient).DownloadString('https://community.chocolatey.org/install.ps1')) - $env:Path += ";$env:ALLUSERSPROFILE\chocolatey\bin" + Install-WithRetry "Chocolatey" { + Set-ExecutionPolicy Bypass -Scope Process -Force + [System.Net.ServicePointManager]::SecurityProtocol = [System.Net.ServicePointManager]::SecurityProtocol -bor 3072 + $scriptUrl = 'https://community.chocolatey.org/install.ps1' + Invoke-Expression (Invoke-WebRequest -UseBasicParsing -Uri $scriptUrl).Content + } + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + + [System.Environment]::GetEnvironmentVariable("Path", "User") } $existingPython = Get-Command python.exe -ErrorAction SilentlyContinue - if ($existingPython) { + $isStoreShim = $false + if ($existingPython -and $existingPython.Source -like "*\Microsoft\WindowsApps\*") { + Write-Log "Found Python Store shim, ignoring..." "WARNING" + $isStoreShim = $true + } + + if ($existingPython -and -not $isStoreShim) { Write-Log "Python already installed: $($existingPython.Source)" "INFO" } else { $targetPythonVersion = $PythonVersion if (-not $targetPythonVersion) { - $targetPythonVersion = Get-PythonVersionFromPyproject (Join-Path $PSScriptRoot "..\..\pyproject.toml") - } - if (-not $targetPythonVersion -and $ZipPath) { - Write-Log "Reading Python version from package archive..." "INFO" - $targetPythonVersion = Get-PythonVersionFromZip $ZipPath + Write-Log "PythonVersion parameter not provided, falling back to 3.14.0" "WARNING" + $targetPythonVersion = "3.14.0" } - if (-not $targetPythonVersion) { - throw "Python version could not be determined. Supply -PythonVersion, ensure pyproject.toml is accessible, or provide a ZipPath containing pyproject.toml." + + # If version is major.minor (e.g. 3.14), append .0 for Chocolatey (3.14.0). + $chocoVersion = $targetPythonVersion + if ($chocoVersion -match '^\d+\.\d+$') { $chocoVersion = "$chocoVersion.0" } + + Install-WithRetry "Python $chocoVersion" { + choco install python --version "$chocoVersion" -y --no-progress --limit-output } - Write-Log "Installing Python $targetPythonVersion..." "INFO" - choco install python --version "$targetPythonVersion.0" -y --no-progress + + # Refresh Path to find the newly installed real Python + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + + [System.Environment]::GetEnvironmentVariable("Path", "User") } if (-not (Get-Command uv.exe -ErrorAction SilentlyContinue)) { - Write-Log "Installing uv..." "INFO" - choco install uv -y --no-progress + Install-WithRetry "uv" { + choco install uv -y --no-progress --limit-output + } } if (-not (Get-Command nssm.exe -ErrorAction SilentlyContinue)) { - Write-Log "Installing NSSM..." "INFO" - choco install nssm -y --no-progress + Install-WithRetry "NSSM" { + choco install nssm -y --no-progress --limit-output + } } + + # Refresh PATH one last time to ensure all new binaries are available + $env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + + [System.Environment]::GetEnvironmentVariable("Path", "User") } # -- Package Acquisition ------------------------------------------------------ @@ -241,7 +235,10 @@ if (-not $ZipPath) { $shaAsset = $release.assets | Where-Object { $_.name -eq "$($zipAsset.name).sha256" } | Select-Object -First 1 - $ZipPath = Join-Path $downloadDir $zipAsset.name + # Use a unique filename per run to avoid file-lock collisions when concurrent + # deployments target the same machine (e.g. two pipeline runs in flight). + $runId = [guid]::NewGuid().ToString().Substring(0, 8) + $ZipPath = Join-Path $downloadDir "$([System.IO.Path]::GetFileNameWithoutExtension($zipAsset.name))-$runId.zip" $downloadHeaders = @{ "Accept" = "application/octet-stream"; "User-Agent" = "Gateway-Deploy-Script" } if ($GitHubToken) { $downloadHeaders["Authorization"] = "Bearer $GitHubToken" } @@ -251,22 +248,34 @@ if (-not $ZipPath) { Write-Log "Downloaded to $ZipPath" "SUCCESS" if ($shaAsset) { - $shaDownloadPath = Join-Path $downloadDir $shaAsset.name + $shaDownloadPath = "$ZipPath.sha256" Invoke-WebRequest -Uri $shaAsset.browser_download_url -Headers $downloadHeaders -OutFile $shaDownloadPath -UseBasicParsing -ErrorAction Stop Write-Log "Downloaded checksum" "SUCCESS" } } +# -- Refresh PATH (Chocolatey updates registry but not the running process) --- + +$env:Path = [System.Environment]::GetEnvironmentVariable("Path", "Machine") + ";" + + [System.Environment]::GetEnvironmentVariable("Path", "User") + +# Arc Run Command runs as SYSTEM; add the explicit Chocolatey Python path as a fallback. +if ($PythonVersion) { + $pyMajorMinor = (($PythonVersion -split '\.')[0..1]) -join '' + $pyPath = "C:\Python$pyMajorMinor" + if (Test-Path $pyPath) { $env:Path += ";$pyPath" } +} + # -- Validation --------------------------------------------------------------- $pythonExe = Get-Command python.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -if (-not $pythonExe) { throw "Python not found in PATH. Run with -Bootstrap or install manually." } +if (-not $pythonExe) { throw "Python not found in PATH. Pass -Bootstrap:`$false to skip bootstrap, or install manually." } $uvExe = Get-Command uv.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -if (-not $uvExe) { throw "uv not found in PATH. Run with -Bootstrap or install manually." } +if (-not $uvExe) { throw "uv not found in PATH. Pass -Bootstrap:`$false to skip bootstrap, or install manually." } $nssmExe = Get-Command nssm.exe -ErrorAction SilentlyContinue | Select-Object -ExpandProperty Source -if (-not $nssmExe) { throw "NSSM not found in PATH. Run with -Bootstrap or install manually." } +if (-not $nssmExe) { throw "NSSM not found in PATH. Pass -Bootstrap:`$false to skip bootstrap, or install manually." } if (-not (Test-Path $ZipPath)) { throw "Package not found at $ZipPath." } @@ -393,12 +402,11 @@ try { foreach ($svc in $services) { $batPath = Join-Path $versionDir "start-$($svc.Name).bat" - $scriptPath = Join-Path "src" $svc.Script $batContent = @( '@echo off', - 'cd /d "%~dp0"', - 'set "PYTHONPATH=src"', - ('".venv\Scripts\python.exe" "' + $scriptPath + '"') + "cd /d `"$BaseInstallPath`"", + 'set "PYTHONPATH=current\src"', + ('"current\.venv\Scripts\python.exe" "current\src\' + $svc.Script + '"') ) -join "`r`n" [System.IO.File]::WriteAllText($batPath, $batContent, [System.Text.Encoding]::ASCII) } @@ -476,7 +484,7 @@ foreach ($svc in $services) { } Invoke-Nssm -NssmPath $nssmExe -Arguments @("install", $svc.Name, "$batPath") -Description "install $($svc.Name)" - Invoke-Nssm -NssmPath $nssmExe -Arguments @("set", $svc.Name, "AppDirectory", "$currentJunction") -Description "set AppDirectory" + Invoke-Nssm -NssmPath $nssmExe -Arguments @("set", $svc.Name, "AppDirectory", "$BaseInstallPath") -Description "set AppDirectory" Invoke-Nssm -NssmPath $nssmExe -Arguments @("set", $svc.Name, "Description", "Manage Breast Screening Gateway - $($svc.Name)") -Description "set Description" Invoke-Nssm -NssmPath $nssmExe -Arguments @("set", $svc.Name, "Start", "SERVICE_AUTO_START") -Description "set Start" @@ -535,7 +543,7 @@ if ($cutoverFailed) { $batPath = Join-Path $currentJunction "start-$($svc.Name).bat" if (Test-Path $batPath) { & $nssmExe set $svc.Name Application "$batPath" 2>&1 | Out-Null - & $nssmExe set $svc.Name AppDirectory "$currentJunction" 2>&1 | Out-Null + & $nssmExe set $svc.Name AppDirectory "$BaseInstallPath" 2>&1 | Out-Null } } diff --git a/scripts/terraform/terraform.mk b/scripts/terraform/terraform.mk index 94a81353..ed9f5947 100644 --- a/scripts/terraform/terraform.mk +++ b/scripts/terraform/terraform.mk @@ -2,6 +2,9 @@ REGION=UK South APP_SHORT_NAME=mbsgw STORAGE_ACCOUNT_RG=rg-dtos-state-files +review: # Target the review environment - make review + $(eval include infrastructure/environments/review/variables.sh) + dev: # Target the dev environment - make dev $(eval include infrastructure/environments/dev/variables.sh) @@ -11,9 +14,6 @@ preprod: # Target the preprod environment - make preprod prod: # Target the prod environment - make prod $(eval include infrastructure/environments/prod/variables.sh) -review: # Target the review environment - make review - $(eval include infrastructure/environments/review/variables.sh) - ci: # Skip manual approvals when running in CI - make ci $(eval AUTO_APPROVE=-auto-approve) $(eval SKIP_AZURE_LOGIN=true) @@ -61,8 +61,13 @@ terraform-plan: terraform-init # Plan Terraform changes - make terraform-p terraform-apply: terraform-init # Apply Terraform changes - make terraform-apply terraform -chdir=infrastructure/terraform apply -var-file ../environments/${ENV_CONFIG}/variables.tfvars ${AUTO_APPROVE} -terraform-destroy: terraform-init # Destroy Terraform resources - make terraform-destroy - terraform -chdir=infrastructure/terraform destroy -var-file ../environments/${ENV_CONFIG}/variables.tfvars ${AUTO_APPROVE} + +terraform-output: terraform-init # Read Terraform outputs from state - make terraform-output + terraform -chdir=infrastructure/terraform output -json terraform-validate: terraform-init-no-backend # Validate Terraform changes - make terraform-validate terraform -chdir=infrastructure/terraform validate + +.PHONY: _install-tool _install-tools ci review dev preprod prod set-azure-account get-subscription-ids \ + resource-group-init terraform-init-no-backend terraform-init terraform-plan terraform-apply \ + terraform-output terraform-validate diff --git a/src/relay_listener.py b/src/relay_listener.py index 5852a1c0..699bd01b 100644 --- a/src/relay_listener.py +++ b/src/relay_listener.py @@ -16,8 +16,8 @@ from dotenv import load_dotenv from websockets.asyncio.client import connect -from websockets.frames import CloseCode from websockets.exceptions import ConnectionClosedError +from websockets.frames import CloseCode from services.mwl.create_worklist_item import CreateWorklistItem from services.storage import MWLStorage diff --git a/src/services/init_worklist_db.sql b/src/services/init_worklist_db.sql index 1885d056..75b3b3de 100644 --- a/src/services/init_worklist_db.sql +++ b/src/services/init_worklist_db.sql @@ -59,4 +59,3 @@ ON worklist_items(patient_id); -- Index for MPPS instance UID lookups CREATE INDEX IF NOT EXISTS idx_worklist_mpps_instance_uid ON worklist_items(mpps_instance_uid); - diff --git a/uv.lock b/uv.lock index 51fd42f3..05376f4c 100644 --- a/uv.lock +++ b/uv.lock @@ -319,7 +319,7 @@ requires-dist = [ { name = "pynetdicom", specifier = ">=3.0.4" }, { name = "python-dotenv", specifier = ">=1.2.1" }, { name = "requests", specifier = ">=2.32.0" }, - { name = "websockets", specifier = "==15.0.1" }, + { name = "websockets", specifier = "==16.0" }, ] [package.metadata.requires-dev] @@ -907,11 +907,29 @@ wheels = [ [[package]] name = "websockets" -version = "15.0.1" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/21/e6/26d09fab466b7ca9c7737474c52be4f76a40301b08362eb2dbc19dcc16c1/websockets-15.0.1.tar.gz", hash = "sha256:82544de02076bafba038ce055ee6412d68da13ab47f0c60cab827346de828dee", size = 177016, upload-time = "2025-03-05T20:03:41.606Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/fa/a8/5b41e0da817d64113292ab1f8247140aac61cbf6cfd085d6a0fa77f4984f/websockets-15.0.1-py3-none-any.whl", hash = "sha256:f7a866fbc1e97b5c617ee4116daaa09b722101d4a3c170c787450ba409f9736f", size = 169743, upload-time = "2025-03-05T20:03:39.41Z" }, +version = "16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/04/24/4b2031d72e840ce4c1ccb255f693b15c334757fc50023e4db9537080b8c4/websockets-16.0.tar.gz", hash = "sha256:5f6261a5e56e8d5c42a4497b364ea24d94d9563e8fbd44e78ac40879c60179b5", size = 179346, upload-time = "2026-01-10T09:23:47.181Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/f3/1d/e88022630271f5bd349ed82417136281931e558d628dd52c4d8621b4a0b2/websockets-16.0-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:8cc451a50f2aee53042ac52d2d053d08bf89bcb31ae799cb4487587661c038a0", size = 177406, upload-time = "2026-01-10T09:23:12.178Z" }, + { url = "https://files.pythonhosted.org/packages/f2/78/e63be1bf0724eeb4616efb1ae1c9044f7c3953b7957799abb5915bffd38e/websockets-16.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:daa3b6ff70a9241cf6c7fc9e949d41232d9d7d26fd3522b1ad2b4d62487e9904", size = 175085, upload-time = "2026-01-10T09:23:13.511Z" }, + { url = "https://files.pythonhosted.org/packages/bb/f4/d3c9220d818ee955ae390cf319a7c7a467beceb24f05ee7aaaa2414345ba/websockets-16.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:fd3cb4adb94a2a6e2b7c0d8d05cb94e6f1c81a0cf9dc2694fb65c7e8d94c42e4", size = 175328, upload-time = "2026-01-10T09:23:14.727Z" }, + { url = "https://files.pythonhosted.org/packages/63/bc/d3e208028de777087e6fb2b122051a6ff7bbcca0d6df9d9c2bf1dd869ae9/websockets-16.0-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:781caf5e8eee67f663126490c2f96f40906594cb86b408a703630f95550a8c3e", size = 185044, upload-time = "2026-01-10T09:23:15.939Z" }, + { url = "https://files.pythonhosted.org/packages/ad/6e/9a0927ac24bd33a0a9af834d89e0abc7cfd8e13bed17a86407a66773cc0e/websockets-16.0-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:caab51a72c51973ca21fa8a18bd8165e1a0183f1ac7066a182ff27107b71e1a4", size = 186279, upload-time = "2026-01-10T09:23:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/b9/ca/bf1c68440d7a868180e11be653c85959502efd3a709323230314fda6e0b3/websockets-16.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:19c4dc84098e523fd63711e563077d39e90ec6702aff4b5d9e344a60cb3c0cb1", size = 185711, upload-time = "2026-01-10T09:23:18.372Z" }, + { url = "https://files.pythonhosted.org/packages/c4/f8/fdc34643a989561f217bb477cbc47a3a07212cbda91c0e4389c43c296ebf/websockets-16.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:a5e18a238a2b2249c9a9235466b90e96ae4795672598a58772dd806edc7ac6d3", size = 184982, upload-time = "2026-01-10T09:23:19.652Z" }, + { url = "https://files.pythonhosted.org/packages/dd/d1/574fa27e233764dbac9c52730d63fcf2823b16f0856b3329fc6268d6ae4f/websockets-16.0-cp314-cp314-win32.whl", hash = "sha256:a069d734c4a043182729edd3e9f247c3b2a4035415a9172fd0f1b71658a320a8", size = 177915, upload-time = "2026-01-10T09:23:21.458Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f1/ae6b937bf3126b5134ce1f482365fde31a357c784ac51852978768b5eff4/websockets-16.0-cp314-cp314-win_amd64.whl", hash = "sha256:c0ee0e63f23914732c6d7e0cce24915c48f3f1512ec1d079ed01fc629dab269d", size = 178381, upload-time = "2026-01-10T09:23:22.715Z" }, + { url = "https://files.pythonhosted.org/packages/06/9b/f791d1db48403e1f0a27577a6beb37afae94254a8c6f08be4a23e4930bc0/websockets-16.0-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:a35539cacc3febb22b8f4d4a99cc79b104226a756aa7400adc722e83b0d03244", size = 177737, upload-time = "2026-01-10T09:23:24.523Z" }, + { url = "https://files.pythonhosted.org/packages/bd/40/53ad02341fa33b3ce489023f635367a4ac98b73570102ad2cdd770dacc9a/websockets-16.0-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:b784ca5de850f4ce93ec85d3269d24d4c82f22b7212023c974c401d4980ebc5e", size = 175268, upload-time = "2026-01-10T09:23:25.781Z" }, + { url = "https://files.pythonhosted.org/packages/74/9b/6158d4e459b984f949dcbbb0c5d270154c7618e11c01029b9bbd1bb4c4f9/websockets-16.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:569d01a4e7fba956c5ae4fc988f0d4e187900f5497ce46339c996dbf24f17641", size = 175486, upload-time = "2026-01-10T09:23:27.033Z" }, + { url = "https://files.pythonhosted.org/packages/e5/2d/7583b30208b639c8090206f95073646c2c9ffd66f44df967981a64f849ad/websockets-16.0-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:50f23cdd8343b984957e4077839841146f67a3d31ab0d00e6b824e74c5b2f6e8", size = 185331, upload-time = "2026-01-10T09:23:28.259Z" }, + { url = "https://files.pythonhosted.org/packages/45/b0/cce3784eb519b7b5ad680d14b9673a31ab8dcb7aad8b64d81709d2430aa8/websockets-16.0-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:152284a83a00c59b759697b7f9e9cddf4e3c7861dd0d964b472b70f78f89e80e", size = 186501, upload-time = "2026-01-10T09:23:29.449Z" }, + { url = "https://files.pythonhosted.org/packages/19/60/b8ebe4c7e89fb5f6cdf080623c9d92789a53636950f7abacfc33fe2b3135/websockets-16.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:bc59589ab64b0022385f429b94697348a6a234e8ce22544e3681b2e9331b5944", size = 186062, upload-time = "2026-01-10T09:23:31.368Z" }, + { url = "https://files.pythonhosted.org/packages/88/a8/a080593f89b0138b6cba1b28f8df5673b5506f72879322288b031337c0b8/websockets-16.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:32da954ffa2814258030e5a57bc73a3635463238e797c7375dc8091327434206", size = 185356, upload-time = "2026-01-10T09:23:32.627Z" }, + { url = "https://files.pythonhosted.org/packages/c2/b6/b9afed2afadddaf5ebb2afa801abf4b0868f42f8539bfe4b071b5266c9fe/websockets-16.0-cp314-cp314t-win32.whl", hash = "sha256:5a4b4cc550cb665dd8a47f868c8d04c8230f857363ad3c9caf7a0c3bf8c61ca6", size = 178085, upload-time = "2026-01-10T09:23:33.816Z" }, + { url = "https://files.pythonhosted.org/packages/9f/3e/28135a24e384493fa804216b79a6a6759a38cc4ff59118787b9fb693df93/websockets-16.0-cp314-cp314t-win_amd64.whl", hash = "sha256:b14dc141ed6d2dde437cddb216004bcac6a1df0935d79656387bd41632ba0bbd", size = 178531, upload-time = "2026-01-10T09:23:35.016Z" }, + { url = "https://files.pythonhosted.org/packages/6f/28/258ebab549c2bf3e64d2b0217b973467394a9cea8c42f70418ca2c5d0d2e/websockets-16.0-py3-none-any.whl", hash = "sha256:1637db62fad1dc833276dded54215f2c7fa46912301a24bd94d45d46a011ceec", size = 171598, upload-time = "2026-01-10T09:23:45.395Z" }, ] [[package]]