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]]