diff --git a/.github/workflows/cicd-2-main-branch.yaml b/.github/workflows/cicd-2-main-branch.yaml index 686a7188..3a72ff8b 100644 --- a/.github/workflows/cicd-2-main-branch.yaml +++ b/.github/workflows/cicd-2-main-branch.yaml @@ -4,16 +4,23 @@ on: push: branches: - main - tags: - - 'v*' workflow_dispatch: + inputs: + environment: + description: "Target environment for infra + app deployment" + required: true + type: choice + default: dev + options: + - dev + - preprod + - review concurrency: cicd-${{ github.ref }} permissions: - contents: write + contents: read id-token: write - attestations: write security-events: write jobs: @@ -26,101 +33,144 @@ jobs: uses: ./.github/workflows/stage-2-test.yaml secrets: inherit - release-stage: + # Resolve the latest release tag once so downstream jobs can consume it. + resolve: + name: Resolve deployment targets needs: test-stage - if: github.ref == 'refs/heads/main' && github.event_name == 'push' runs-on: ubuntu-latest outputs: - new_release_published: ${{ steps.release.outputs.new_release_published }} - new_release_version: ${{ steps.release.outputs.new_release_version }} + release_tag: ${{ steps.tag.outputs.release_tag }} + deploy_dev: ${{ steps.envs.outputs.deploy_dev }} + deploy_preprod: ${{ steps.envs.outputs.deploy_preprod }} + deploy_review: ${{ steps.envs.outputs.deploy_review }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - - name: Checkout code - uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 - with: - fetch-depth: 0 - token: ${{ secrets.GITHUB_TOKEN }} - - - name: Read tool versions - id: tool-versions + - name: Compute target environments + id: envs run: | - echo "python=$(awk '/^python / {print $2}' .tool-versions)" >> "$GITHUB_OUTPUT" - echo "uv=$(awk '/^uv / {print $2}' .tool-versions)" >> "$GITHUB_OUTPUT" - - - name: Set up Python - uses: actions/setup-python@a309ff8b426b58ec0e2a45f0f869d46889d02405 # v6.2.0 - with: - python-version: ${{ steps.tool-versions.outputs.python }} - - - name: Install uv - uses: astral-sh/setup-uv@cec208311dfd045dd5311c1add060b2062131d57 # v8.0.0 - with: - version: ${{ steps.tool-versions.outputs.uv }} - enable-cache: true - - - name: Run semantic-release - id: release - env: - GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + INPUT="${{ inputs.environment }}" + case "$INPUT" in + dev) + echo "deploy_dev=true" >> "$GITHUB_OUTPUT" + echo "deploy_preprod=false" >> "$GITHUB_OUTPUT" + echo "deploy_review=false" >> "$GITHUB_OUTPUT" + ;; + preprod) + echo "deploy_dev=false" >> "$GITHUB_OUTPUT" + echo "deploy_preprod=true" >> "$GITHUB_OUTPUT" + echo "deploy_review=false" >> "$GITHUB_OUTPUT" + ;; + review) + echo "deploy_dev=false" >> "$GITHUB_OUTPUT" + echo "deploy_preprod=false" >> "$GITHUB_OUTPUT" + echo "deploy_review=true" >> "$GITHUB_OUTPUT" + ;; + *) + # push-to-main (empty input): deploy to both dev and preprod + echo "deploy_dev=true" >> "$GITHUB_OUTPUT" + echo "deploy_preprod=true" >> "$GITHUB_OUTPUT" + echo "deploy_review=false" >> "$GITHUB_OUTPUT" + ;; + esac + + - name: Resolve latest release tag + id: tag run: | - uv pip install python-semantic-release --system + LATEST_TAG=$(gh release list \ + --repo "${{ github.repository }}" \ + --limit 1 \ + --json tagName --jq '.[0].tagName' 2>/dev/null || echo "") + if [[ -z "$LATEST_TAG" ]]; then + echo "::error::No GitHub Release exists. Push a v* tag to create the first release." + exit 1 + fi + echo "release_tag=${LATEST_TAG}" >> "$GITHUB_OUTPUT" + echo "Latest release: ${LATEST_TAG}" - # Detect next version (--print exits without side effects) - VERSION=$(semantic-release version --print 2>/dev/null) || true + # ---- dev ------------------------------------------------------------------ - if [[ -n "$VERSION" ]] && ! git ls-remote --tags origin "refs/tags/v$VERSION" | grep -q .; then - echo "Next version detected: $VERSION" + deploy-infra-dev: + name: Deploy infra (dev) + needs: resolve + if: needs.resolve.outputs.deploy_dev == 'true' + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy.yaml + with: + environments: '["dev"]' + commit_sha: ${{ github.sha }} + secrets: inherit - # Create and push the tag (no commit needed, tag push is not blocked by branch protection) - git tag "v$VERSION" - git push origin "v$VERSION" + deploy-app-dev: + name: Deploy app (dev) + needs: [resolve, deploy-infra-dev] + if: needs.resolve.outputs.deploy_dev == 'true' + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy-app.yaml + with: + environments: '["dev"]' + release_tag: ${{ needs.resolve.outputs.release_tag }} + commit_sha: ${{ github.sha }} + secrets: inherit - echo "new_release_published=true" >> "$GITHUB_OUTPUT" - echo "new_release_version=v$VERSION" >> "$GITHUB_OUTPUT" - else - echo "No new release needed (version: ${VERSION:-none}, tag may already exist)" - echo "new_release_published=false" >> "$GITHUB_OUTPUT" - echo "new_release_version=" >> "$GITHUB_OUTPUT" - fi + # ---- preprod -------------------------------------------------------------- + + deploy-infra-preprod: + name: Deploy infra (preprod) + needs: [resolve, deploy-app-dev] + if: | + always() && + needs.resolve.outputs.deploy_preprod == 'true' && + (needs.deploy-app-dev.result == 'success' || needs.deploy-app-dev.result == 'skipped') + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy.yaml + with: + environments: '["preprod"]' + commit_sha: ${{ github.sha }} + secrets: inherit - build-stage: - needs: [test-stage, release-stage] + deploy-app-preprod: + name: Deploy app (preprod) + needs: [resolve, deploy-infra-preprod] if: | always() && - needs.test-stage.result == 'success' && - ( - github.ref_type == 'tag' || - needs.release-stage.outputs.new_release_published == 'true' - ) - uses: ./.github/workflows/stage-3-build.yaml + needs.resolve.outputs.deploy_preprod == 'true' && + needs.deploy-infra-preprod.result == 'success' + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy-app.yaml with: - version: ${{ needs.release-stage.outputs.new_release_version }} - new_release_published: ${{ needs.release-stage.outputs.new_release_published == 'true' }} + environments: '["preprod"]' + release_tag: ${{ needs.resolve.outputs.release_tag }} + commit_sha: ${{ github.sha }} secrets: inherit - deploy-stage: - name: Deploy stage - needs: [commit-stage, test-stage] + # ---- review (manual only) ------------------------------------------------- + + deploy-infra-review: + name: Deploy infra (review) + needs: resolve + if: needs.resolve.outputs.deploy_review == 'true' permissions: id-token: write uses: ./.github/workflows/stage-4-deploy.yaml with: - environments: '["review", "dev", "preprod"]' + 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' + deploy-app-review: + name: Deploy app (review) + needs: [resolve, deploy-infra-review] + if: needs.resolve.outputs.deploy_review == 'true' permissions: id-token: write uses: ./.github/workflows/stage-4-deploy-app.yaml with: - environments: '["dev", "preprod"]' - release_tag: ${{ needs.release-stage.outputs.new_release_version }} + environments: '["review"]' + release_tag: ${{ needs.resolve.outputs.release_tag }} commit_sha: ${{ github.sha }} secrets: inherit diff --git a/.github/workflows/cicd-3-release.yaml b/.github/workflows/cicd-3-release.yaml new file mode 100644 index 00000000..58289e61 --- /dev/null +++ b/.github/workflows/cicd-3-release.yaml @@ -0,0 +1,74 @@ +name: "CI/CD: Release" + +# Prod requires a manual approval — configure required reviewers in GitHub +# Settings → Environments → prod before provisioning the prod environment. + +on: + push: + tags: + - 'v*' + +concurrency: release-${{ github.ref }} + +permissions: + contents: write + id-token: write + attestations: write + security-events: write + +jobs: + commit-stage: + uses: ./.github/workflows/stage-1-commit.yaml + secrets: inherit + + test-stage: + needs: commit-stage + uses: ./.github/workflows/stage-2-test.yaml + secrets: inherit + + build-stage: + needs: test-stage + uses: ./.github/workflows/stage-3-build.yaml + with: + version: ${{ github.ref_name }} + new_release_published: true + secrets: inherit + + deploy-dev: + name: Deploy (dev) + needs: build-stage + uses: ./.github/workflows/stage-4-deploy-env.yaml + with: + environment: dev + release_tag: ${{ github.ref_name }} + commit_sha: ${{ github.sha }} + secrets: inherit + + deploy-preprod: + name: Deploy (preprod) + needs: deploy-dev + uses: ./.github/workflows/stage-4-deploy-env.yaml + with: + environment: preprod + release_tag: ${{ github.ref_name }} + commit_sha: ${{ github.sha }} + secrets: inherit + + prod-approval: + name: Awaiting prod approval + needs: deploy-preprod + runs-on: ubuntu-latest + environment: prod + steps: + - name: Approved + run: echo "Prod deployment approved - proceeding" + + deploy-prod: + name: Deploy (prod) + needs: prod-approval + uses: ./.github/workflows/stage-4-deploy-env.yaml + with: + environment: prod + release_tag: ${{ github.ref_name }} + commit_sha: ${{ github.sha }} + secrets: inherit diff --git a/.github/workflows/stage-4-deploy-env.yaml b/.github/workflows/stage-4-deploy-env.yaml new file mode 100644 index 00000000..c1b9b2d6 --- /dev/null +++ b/.github/workflows/stage-4-deploy-env.yaml @@ -0,0 +1,61 @@ +name: "Stage 4: Deploy to environment" + +on: + workflow_call: + inputs: + environment: + description: Target environment (dev / preprod / prod) + required: true + type: string + release_tag: + description: GitHub release tag to deploy (e.g. v1.2.3) + required: true + type: string + commit_sha: + description: Commit SHA used to trigger the ADO pipeline + required: true + type: string + +jobs: + deploy-infra: + name: Deploy infra + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy.yaml + with: + environments: '["${{ inputs.environment }}"]' + commit_sha: ${{ inputs.commit_sha }} + secrets: inherit + + deploy-app: + name: Deploy app + needs: deploy-infra + permissions: + id-token: write + uses: ./.github/workflows/stage-4-deploy-app.yaml + with: + environments: '["${{ inputs.environment }}"]' + release_tag: ${{ inputs.release_tag }} + commit_sha: ${{ inputs.commit_sha }} + secrets: inherit + + smoke-test: + name: Smoke test + needs: deploy-app + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + permissions: + id-token: write + steps: + - name: Checkout code + uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2 + + - name: Azure login + uses: azure/login@532459ea530d8321f2fb9bb10d1e0bcf23869a43 # v3.0.0 + with: + client-id: ${{ secrets.AZURE_CLIENT_ID }} + tenant-id: ${{ secrets.AZURE_TENANT_ID }} + subscription-id: ${{ secrets.AZURE_SUBSCRIPTION_ID }} + + - name: Run smoke test + run: bash scripts/bash/smoke_test.sh ${{ inputs.environment }} diff --git a/scripts/bash/smoke_test.sh b/scripts/bash/smoke_test.sh new file mode 100755 index 00000000..2ace8994 --- /dev/null +++ b/scripts/bash/smoke_test.sh @@ -0,0 +1,120 @@ +#!/usr/bin/env bash +# Run a smoke test on all Arc-enabled gateway VMs in the target environment. +# +# Uses az connectedmachine run-command to execute scripts/powershell/smoke_test.ps1 +# on each VM via the Azure management plane. No direct network access to hospital +# machines is required — the Arc agent handles the connection. +# +# Usage: bash scripts/bash/smoke_test.sh +# environment: dev | preprod | prod +# +# Prerequisites: +# - Azure login (az login or OIDC via GitHub Actions) +# - connectedmachine CLI extension (installed automatically if missing) + +set -euo pipefail + +ENVIRONMENT="${1:?Usage: smoke_test.sh }" + +# shellcheck source=/dev/null +source "infrastructure/environments/${ENVIRONMENT}/variables.sh" + +RESOURCE_GROUP="rg-mbsgw-${ENVIRONMENT}-uks-arc-enabled-servers" +LOCATION="uksouth" +SCRIPT_CONTENT=$(cat scripts/powershell/smoke_test.ps1) + +echo "Installing connectedmachine CLI extension..." +az extension add --name connectedmachine --yes --output none 2>/dev/null || true + +echo "Listing Arc machines in ${RESOURCE_GROUP}..." +MACHINES=$(az connectedmachine list \ + --resource-group "${RESOURCE_GROUP}" \ + --query "[].name" \ + --output tsv 2>/dev/null || echo "") + +if [[ -z "$MACHINES" ]]; then + echo "No Arc machines found in ${RESOURCE_GROUP} — skipping smoke test" + exit 0 +fi + +FAILED_MACHINES=() + +while IFS= read -r MACHINE; do + [[ -z "$MACHINE" ]] && continue + + RUN_NAME="smoke-$(date +%s)" + echo "" + echo "--- Smoke test: ${MACHINE} (run command: ${RUN_NAME}) ---" + + az connectedmachine run-command create \ + --name "${RUN_NAME}" \ + --machine-name "${MACHINE}" \ + --resource-group "${RESOURCE_GROUP}" \ + --location "${LOCATION}" \ + --script "${SCRIPT_CONTENT}" \ + --output none + + # Poll until the run command completes (max 5 minutes, 10s interval) + PASSED=false + for i in $(seq 1 30); do + EXEC_STATE=$(az connectedmachine run-command show \ + --name "${RUN_NAME}" \ + --machine-name "${MACHINE}" \ + --resource-group "${RESOURCE_GROUP}" \ + --instance-view \ + --query "instanceView.executionState" \ + --output tsv 2>/dev/null || echo "Unknown") + + if [[ "$EXEC_STATE" == "Succeeded" ]]; then + echo "✓ ${MACHINE}: smoke test passed" + PASSED=true + break + elif [[ "$EXEC_STATE" == "Failed" ]]; then + OUTPUT=$(az connectedmachine run-command show \ + --name "${RUN_NAME}" \ + --machine-name "${MACHINE}" \ + --resource-group "${RESOURCE_GROUP}" \ + --instance-view \ + --query "instanceView.output" \ + --output tsv 2>/dev/null || echo "(no output)") + ERROR=$(az connectedmachine run-command show \ + --name "${RUN_NAME}" \ + --machine-name "${MACHINE}" \ + --resource-group "${RESOURCE_GROUP}" \ + --instance-view \ + --query "instanceView.error" \ + --output tsv 2>/dev/null || echo "(no error)") + echo "✗ ${MACHINE}: smoke test FAILED" + echo " Output: ${OUTPUT}" + echo " Error: ${ERROR}" + break + fi + + if [[ $i -eq 30 ]]; then + echo "✗ ${MACHINE}: timed out after 5 minutes (last state: ${EXEC_STATE})" + else + echo " [${i}/30] ${EXEC_STATE} — waiting 10s..." + sleep 10 + fi + done + + # Clean up the run command resource regardless of outcome + az connectedmachine run-command delete \ + --name "${RUN_NAME}" \ + --machine-name "${MACHINE}" \ + --resource-group "${RESOURCE_GROUP}" \ + --yes --output none 2>/dev/null || true + + if [[ "$PASSED" != "true" ]]; then + FAILED_MACHINES+=("${MACHINE}") + fi + +done <<< "$MACHINES" + +echo "" +if [[ ${#FAILED_MACHINES[@]} -gt 0 ]]; then + echo "::error::Smoke test failed on: ${FAILED_MACHINES[*]}" + exit 1 +fi + +echo "All smoke tests passed for ${ENVIRONMENT}" diff --git a/scripts/powershell/deploy.ps1 b/scripts/powershell/deploy.ps1 index 5c41a552..d32dae05 100644 --- a/scripts/powershell/deploy.ps1 +++ b/scripts/powershell/deploy.ps1 @@ -80,6 +80,25 @@ if ($EnvContentB64) { Write-Log "Written .env to $BaseInstallPath" "SUCCESS" } +# -- Version Check (skip reinstall if already on this version) ---------------- +# +# deploy.ps1 writes a VERSION file after every successful deployment. +# On subsequent runs with the same tag, we exit early to avoid redundant +# reinstalls (e.g. on every main-branch merge when no new release exists). +# New VMs have no VERSION file and always proceed with installation. +# When ReleaseTag is "latest", skip the check — the resolved version is unknown +# at this point and the check would never match. + +$versionFile = Join-Path $BaseInstallPath "VERSION" +if ($ReleaseTag -ne "latest" -and (Test-Path $versionFile)) { + $currentVersion = (Get-Content $versionFile -Raw).Trim() + if ($currentVersion -eq $ReleaseTag) { + Write-Log "Already running $ReleaseTag - skipping deployment" "INFO" + exit 0 + } + Write-Log "Upgrading from $currentVersion to $ReleaseTag" "INFO" +} + # -- Helpers ------------------------------------------------------------------ function Invoke-Nssm { @@ -323,7 +342,7 @@ if (Test-Path $versionDir) { Stop-AllServices -Services $services -TimeoutSeconds $ServiceStopTimeoutSeconds } -$stagingDir = Join-Path $env:TEMP "gateway-deploy-staging-$([guid]::NewGuid().ToString().Substring(0,8))" +$stagingDir = Join-Path $BaseInstallPath "staging-$([guid]::NewGuid().ToString().Substring(0,8))" New-Item -ItemType Directory -Path $stagingDir -Force | Out-Null Add-Type -Assembly System.IO.Compression.FileSystem @@ -560,3 +579,8 @@ if ($cutoverFailed) { $cutoverDuration = ((Get-Date) - $cutoverStart).TotalSeconds Write-Log "Deployment of version $version completed in $([math]::Round($cutoverDuration, 2))s." "SUCCESS" + +# Record the deployed release tag so subsequent runs can skip reinstallation. +$versionFile = Join-Path $BaseInstallPath "VERSION" +[System.IO.File]::WriteAllText($versionFile, $ReleaseTag, (New-Object System.Text.UTF8Encoding $false)) +Write-Log "Written VERSION: $ReleaseTag" "INFO" diff --git a/scripts/powershell/smoke_test.ps1 b/scripts/powershell/smoke_test.ps1 new file mode 100644 index 00000000..445b8e35 --- /dev/null +++ b/scripts/powershell/smoke_test.ps1 @@ -0,0 +1,62 @@ +#Requires -Version 5.1 +<# +.SYNOPSIS + Smoke test for the gateway services. +.DESCRIPTION + Checks all 4 Windows services are Running, then sends a DICOM C-ECHO to the + PACS server on localhost to verify it is accepting connections. + Executed on the Arc-enabled VM via az connectedmachine run-command. +#> + +$ErrorActionPreference = 'Stop' + +Write-Output "=== Gateway Smoke Test ===" + +# -- Service check ------------------------------------------------------------ + +$serviceNames = 'Gateway-PACS', 'Gateway-MWL', 'Gateway-Upload', 'Gateway-Relay' +foreach ($name in $serviceNames) { + $svc = Get-Service -Name $name -ErrorAction Stop + if ($svc.Status -ne 'Running') { + throw "Service $name is not running (status: $($svc.Status))" + } + Write-Output "OK: $name is Running" +} + +# -- DICOM C-ECHO ------------------------------------------------------------- + +$installPath = 'C:\Program Files\NHS\ManageBreastScreeningGateway\current' +if (-not (Test-Path $installPath)) { + throw "Gateway install path not found: $installPath" +} + +Set-Location $installPath +$env:PYTHONPATH = 'src' + +$echoScript = @' +from pynetdicom import AE +from pynetdicom.sop_class import Verification +ae = AE() +ae.add_requested_context(Verification) +assoc = ae.associate("127.0.0.1", 4244) +if not assoc.is_established: + raise SystemExit("PACS association failed — check Gateway-PACS service logs") +status = assoc.send_c_echo() +assoc.release() +if not status or status.Status != 0x0000: + raise SystemExit("C-ECHO returned unexpected status: " + hex(status.Status if status else 0)) +print("C-ECHO OK") +'@ + +$tempPy = Join-Path $env:TEMP 'gw_smoke_echo.py' +try { + [System.IO.File]::WriteAllText($tempPy, $echoScript, [System.Text.Encoding]::UTF8) + & '.venv\Scripts\python.exe' $tempPy + if ($LASTEXITCODE -ne 0) { + throw "DICOM C-ECHO failed (exit code: $LASTEXITCODE)" + } +} finally { + Remove-Item $tempPy -ErrorAction SilentlyContinue +} + +Write-Output "=== Smoke test passed ==="