diff --git a/.github/workflows/build-windows-a11y-ami.yml b/.github/workflows/build-windows-a11y-ami.yml index 68da3cd..a39edc7 100644 --- a/.github/workflows/build-windows-a11y-ami.yml +++ b/.github/workflows/build-windows-a11y-ami.yml @@ -75,21 +75,7 @@ jobs: - name: Install Windows Updates (repeat until converged) run: | - for i in $(seq 1 5); do - OUTPUT=$(bash scripts/windows-a11y/ssm-run.sh "${{ steps.launch.outputs.instance_id }}" scripts/windows-a11y/install-updates.ps1 3600) - echo "${OUTPUT}" - if echo "${OUTPUT}" | grep -q "No updates found."; then - echo "No further updates." - break - fi - if echo "${OUTPUT}" | grep -q "REBOOT_REQUIRED=true"; then - echo "Rebooting instance for updates (pass ${i})..." - aws ec2 reboot-instances --instance-ids "${{ steps.launch.outputs.instance_id }}" - sleep 30 - aws ec2 wait instance-status-ok --instance-ids "${{ steps.launch.outputs.instance_id }}" - fi - done - echo "WINDOWS_UPDATE_DATE=$(date -u +%Y-%m-%d)" >> "$GITHUB_ENV" + bash scripts/windows-a11y/run-windows-updates.sh "${{ steps.launch.outputs.instance_id }}" - name: Install/update Chrome, Firefox, NVDA id: software @@ -148,13 +134,9 @@ jobs: id: create-image run: | AMI_NAME="windows-a11y-${{ github.event.inputs.ami_name }}" - IMAGE_ID=$(aws ec2 create-image \ - --instance-id "${{ steps.launch.outputs.instance_id }}" \ - --name "${AMI_NAME}" \ - --description "Windows Server 2025 A11y test environment - ${AMI_NAME}" \ - --query 'ImageId' --output text) - aws ec2 wait image-available --image-ids "${IMAGE_ID}" - echo "ami_id=${IMAGE_ID}" >> "$GITHUB_OUTPUT" + bash scripts/windows-a11y/create-ami.sh \ + "${{ steps.launch.outputs.instance_id }}" \ + "${AMI_NAME}" - name: Tag AMI and snapshots run: | diff --git a/.github/workflows/launch-windows-a11y-ec2.yml b/.github/workflows/launch-windows-a11y-ec2.yml index 5e4a895..7a2fb30 100644 --- a/.github/workflows/launch-windows-a11y-ec2.yml +++ b/.github/workflows/launch-windows-a11y-ec2.yml @@ -1,29 +1,40 @@ -name: Launch Windows A11y EC2 +name: Manage Windows A11y EC2 on: workflow_dispatch: inputs: - ami_name: - description: "AMI 版本標籤(需與 build-windows-a11y-ami 的 ami_name 相同)" + action: + description: "Operation to perform" required: true - type: string - stack_name: - description: "CloudFormation stack name" + default: "launch" + type: choice + options: + - launch + - delete + stack_suffix: + description: "Stack suffix only. Example: anson-test creates windows-a11y-anson-test" required: true - default: "windows-a11y" + type: string + confirm_stack_name: + description: "Delete only: enter the full name, including prefix (example: windows-a11y-anson-test)" + required: false + type: string + ami_name: + description: "Launch only: AMI version label used by build-windows-a11y-ami" + required: false type: string instance_name: - description: "EC2 Name tag" + description: "Launch only: EC2 Name tag" required: true default: "windows-a11y" type: string instance_type: - description: "EC2 instance type" + description: "Launch only: EC2 instance type" required: true default: "m5.xlarge" type: string disk_size: - description: "Root EBS volume size (GiB)" + description: "Launch only: root EBS volume size (GiB)" required: true default: "100" type: string @@ -35,8 +46,42 @@ env: AWS_REGION: ap-northeast-1 jobs: + validate: + name: validate stack operation + runs-on: ubuntu-latest + outputs: + stack_name: ${{ steps.stack.outputs.stack_name }} + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Validate request and resolve stack name + id: stack + env: + ACTION: ${{ inputs.action }} + AMI_NAME: ${{ inputs.ami_name }} + CONFIRM_STACK_NAME: ${{ inputs.confirm_stack_name }} + STACK_SUFFIX: ${{ inputs.stack_suffix }} + run: | + STACK_NAME=$(bash scripts/windows-a11y/validate-stack-operation.sh \ + "${ACTION}" \ + "${STACK_SUFFIX}" \ + "${CONFIRM_STACK_NAME}" \ + "${AMI_NAME}") + + echo "Resolved stack name: ${STACK_NAME}" + echo "stack_name=${STACK_NAME}" >> "$GITHUB_OUTPUT" + { + echo "## Windows A11y EC2 request" + echo "- Action: \`${ACTION}\`" + echo "- Stack prefix: \`windows-a11y-\`" + echo "- Full stack name: \`${STACK_NAME}\`" + } >> "$GITHUB_STEP_SUMMARY" + launch: name: launch ec2 from windows a11y ami + if: ${{ inputs.action == 'launch' }} + needs: validate environment: windows-a11y runs-on: ubuntu-latest permissions: @@ -77,7 +122,7 @@ jobs: DISK_SIZE: ${{ inputs.disk_size }} INSTANCE_NAME: ${{ inputs.instance_name }} INSTANCE_TYPE: ${{ inputs.instance_type }} - STACK_NAME: ${{ inputs.stack_name }} + STACK_NAME: ${{ needs.validate.outputs.stack_name }} run: | aws cloudformation deploy \ --stack-name "${STACK_NAME}" \ @@ -96,7 +141,7 @@ jobs: - name: Wait for instance and publish connection details env: AMI_ID: ${{ steps.ami.outputs.image_id }} - STACK_NAME: ${{ inputs.stack_name }} + STACK_NAME: ${{ needs.validate.outputs.stack_name }} run: | INSTANCE_ID=$(aws cloudformation describe-stacks \ --stack-name "${STACK_NAME}" \ @@ -115,3 +160,47 @@ jobs: echo "- Public IP: \`${PUBLIC_IP}\`" echo "- Stack: \`${STACK_NAME}\`" } >> "$GITHUB_STEP_SUMMARY" + + delete: + name: delete windows a11y stack + if: ${{ inputs.action == 'delete' }} + needs: validate + environment: windows-a11y + runs-on: ubuntu-latest + permissions: + id-token: write + contents: read + steps: + - name: Configure AWS credentials + uses: aws-actions/configure-aws-credentials@v4 + with: + role-to-assume: ${{ secrets.AWS_GITHUB_ACTION_ROLE }} + aws-region: ${{ env.AWS_REGION }} + + - name: Verify and delete CloudFormation stack + env: + STACK_NAME: ${{ needs.validate.outputs.stack_name }} + run: | + STACK_STATUS=$(aws cloudformation describe-stacks \ + --stack-name "${STACK_NAME}" \ + --query 'Stacks[0].StackStatus' \ + --output text) + + echo "Deleting ${STACK_NAME} (current status: ${STACK_STATUS})." + aws cloudformation delete-stack --stack-name "${STACK_NAME}" + + if ! aws cloudformation wait stack-delete-complete --stack-name "${STACK_NAME}"; then + echo "::error::CloudFormation did not complete deletion of ${STACK_NAME}." + aws cloudformation describe-stack-events \ + --stack-name "${STACK_NAME}" \ + --max-items 10 \ + --query 'StackEvents[].{Time:Timestamp,Status:ResourceStatus,Type:ResourceType,Reason:ResourceStatusReason}' \ + --output table || true + exit 1 + fi + + { + echo "## Windows A11y stack deleted" + echo "- Stack: \`${STACK_NAME}\`" + echo "- Previous status: \`${STACK_STATUS}\`" + } >> "$GITHUB_STEP_SUMMARY" diff --git a/docs/windows-a11y-aws-manual-setup.md b/docs/windows-a11y-aws-manual-setup.md index d7e1a25..1e57022 100644 --- a/docs/windows-a11y-aws-manual-setup.md +++ b/docs/windows-a11y-aws-manual-setup.md @@ -28,7 +28,7 @@ reliable than using the localized base image. - Source: `Custom` → enter your office/VPN CIDR block (e.g. `203.0.113.0/24`) — do **not** use `0.0.0.0/0`. - Description: `Office VPN RDP access` 6. **Outbound rules**: leave the default (all traffic allowed) — the instance needs outbound HTTPS for - Windows Update, Chocolatey, and the SSM agent. + Windows Update, Google, Mozilla, NV Access, and the SSM agent. 7. **Tags**: `Name` = `windows-a11y-rdp`. 8. Click **Create security group**. Copy the resulting **Security group ID** (e.g. `sg-0123456789abcdef0`). 9. Record this value — it becomes the `SECURITY_GROUP_ID` GitHub variable in step 5. diff --git a/scripts/windows-a11y/create-ami.sh b/scripts/windows-a11y/create-ami.sh new file mode 100644 index 0000000..46ecc90 --- /dev/null +++ b/scripts/windows-a11y/create-ami.sh @@ -0,0 +1,71 @@ +#!/usr/bin/env bash +set -euo pipefail + +INSTANCE_ID="${1:?Usage: create-ami.sh }" +AMI_NAME="${2:?Usage: create-ami.sh }" +: "${GITHUB_OUTPUT:?GITHUB_OUTPUT must point to the GitHub Actions output file}" + +MAX_ATTEMPTS="${AMI_MAX_ATTEMPTS:-120}" +POLL_INTERVAL_SECONDS="${AMI_POLL_INTERVAL_SECONDS:-15}" +LOG_PREFIX="[create-ami]" + +IMAGE_ID=$(aws ec2 create-image \ + --instance-id "${INSTANCE_ID}" \ + --name "${AMI_NAME}" \ + --description "Windows Server 2025 A11y test environment - ${AMI_NAME}" \ + --query 'ImageId' \ + --output text) + +echo "${LOG_PREFIX} Created AMI ${IMAGE_ID}." +echo "ami_id=${IMAGE_ID}" >> "${GITHUB_OUTPUT}" + +for attempt in $(seq 1 "${MAX_ATTEMPTS}"); do + set +e + IMAGE_JSON=$(aws ec2 describe-images --image-ids "${IMAGE_ID}" --output json 2>&1) + DESCRIBE_STATUS=$? + set -e + + if (( DESCRIBE_STATUS != 0 )); then + if grep -q 'InvalidAMIID.NotFound' <<< "${IMAGE_JSON}"; then + echo "${LOG_PREFIX} AMI ${IMAGE_ID} is not visible yet (attempt ${attempt} of ${MAX_ATTEMPTS})." + if (( attempt < MAX_ATTEMPTS )); then + sleep "${POLL_INTERVAL_SECONDS}" + fi + continue + fi + + echo "${IMAGE_JSON}" >&2 + exit "${DESCRIBE_STATUS}" + fi + + STATE=$(jq -r '.Images[0].State // "missing"' <<< "${IMAGE_JSON}") + STATE_REASON=$(jq -r '.Images[0].StateReason.Message // empty' <<< "${IMAGE_JSON}") + + if [[ -n "${STATE_REASON}" ]]; then + echo "${LOG_PREFIX} AMI ${IMAGE_ID} state: ${STATE} (attempt ${attempt} of ${MAX_ATTEMPTS}); reason: ${STATE_REASON}." + else + echo "${LOG_PREFIX} AMI ${IMAGE_ID} state: ${STATE} (attempt ${attempt} of ${MAX_ATTEMPTS})." + fi + + case "${STATE}" in + available) + exit 0 + ;; + failed) + echo "${LOG_PREFIX} AMI ${IMAGE_ID} creation failed." >&2 + exit 1 + ;; + pending) + if (( attempt < MAX_ATTEMPTS )); then + sleep "${POLL_INTERVAL_SECONDS}" + fi + ;; + *) + echo "${LOG_PREFIX} Unexpected AMI state '${STATE}' for ${IMAGE_ID}." >&2 + exit 1 + ;; + esac +done + +echo "${LOG_PREFIX} Timed out waiting for AMI ${IMAGE_ID} after ${MAX_ATTEMPTS} checks." >&2 +exit 1 diff --git a/scripts/windows-a11y/install-software.ps1 b/scripts/windows-a11y/install-software.ps1 index fe62be5..ed18f90 100644 --- a/scripts/windows-a11y/install-software.ps1 +++ b/scripts/windows-a11y/install-software.ps1 @@ -1,19 +1,110 @@ [CmdletBinding()] -param() +param([switch]$SkipExecution) $ErrorActionPreference = 'Stop' $logPrefix = '[install-software]' +$nvdaStableBaseUri = [Uri]'https://download.nvaccess.org/releases/stable/' -if (-not (Get-Command choco -ErrorAction SilentlyContinue)) { - Write-Output "$logPrefix Installing Chocolatey..." - 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:Path;C:\ProgramData\chocolatey\bin" +function Get-FirefoxInstallerUri { + return [Uri]'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' } -$chocoPath = (Get-Command choco -ErrorAction Stop).Source -$successfulExitCodes = @(0, 2, 1641, 3010) +function Get-NvdaStableInstallerUri { + param( + [Parameter(Mandatory = $true)][string]$Content, + [Uri]$BaseUri = $nvdaStableBaseUri + ) + + $hrefPattern = 'href=["''](?nvda_(?\d{4}\.\d+(?:\.\d+)?)\.exe)["'']' + $matches = @([regex]::Matches($Content, $hrefPattern, 'IgnoreCase')) + if ($matches.Count -ne 1) { + throw "$logPrefix Expected exactly one numeric stable NVDA installer, found $($matches.Count)." + } + return [Uri]::new($BaseUri, $matches[0].Groups['href'].Value) +} + +function Find-NvdaExecutable { + $candidates = @( + 'C:\Program Files\NVDA\nvda.exe' + 'C:\Program Files (x86)\NVDA\nvda.exe' + ) + + return $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +} + +function Invoke-WebRequestWithRetry { + param( + [Parameter(Mandatory = $true)][Uri]$Uri, + [string]$OutFile, + [string]$Operation = 'download', + [ValidateRange(1, 3)][int]$MaxAttempts = 3 + ) + for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { + try { + $parameters = @{ Uri = $Uri; UseBasicParsing = $true } + if ($OutFile) { $parameters.OutFile = $OutFile } + return Invoke-WebRequest @parameters + } catch { + if ($attempt -eq $MaxAttempts) { + throw "$logPrefix Download from $Uri failed after $MaxAttempts attempts: $($_.Exception.Message)" + } + $delaySeconds = 15 * $attempt + Write-Warning "$logPrefix $Operation attempt $attempt of $MaxAttempts failed; retrying in $delaySeconds seconds." + Start-Sleep -Seconds $delaySeconds + } + } +} + +function Assert-AuthenticodePublisher { + param( + [Parameter(Mandatory = $true)][string]$Path, + [Parameter(Mandatory = $true)][string]$ProductName, + [Parameter(Mandatory = $true)][string]$PublisherPattern + ) + $signature = Get-AuthenticodeSignature -FilePath $Path + $publisher = if ($signature.SignerCertificate) { + $signature.SignerCertificate.Subject + } else { + '' + } + if ($signature.Status -ne 'Valid' -or $publisher -notmatch $PublisherPattern) { + throw "$logPrefix $ProductName signature verification failed (status: $($signature.Status), publisher: $publisher)" + } +} + +function Assert-InstallerExitCode { + param([string]$ProductName, [int]$ExitCode) + if ($ExitCode -ne 0) { + throw "$logPrefix $ProductName installation failed with exit code $ExitCode." + } +} + +function Install-Firefox { + $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' + $installerPath = Join-Path $downloadDirectory 'firefox-zh-TW-win64-latest.exe' + New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null + Invoke-WebRequestWithRetry -Uri (Get-FirefoxInstallerUri) -OutFile $installerPath ` + -Operation 'Firefox installer download' + Assert-AuthenticodePublisher -Path $installerPath -ProductName 'Firefox' ` + -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' + $process = Start-Process -FilePath $installerPath -ArgumentList @('/S') -Wait -PassThru + Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode $process.ExitCode + Remove-Item -LiteralPath $installerPath -Force +} + +function Install-Nvda { + $downloadDirectory = Join-Path $env:TEMP 'windows-a11y-installers' + New-Item -ItemType Directory -Path $downloadDirectory -Force | Out-Null + $listing = Invoke-WebRequestWithRetry -Uri $nvdaStableBaseUri -Operation 'NVDA stable listing download' + $installerUri = Get-NvdaStableInstallerUri -Content $listing.Content + $installerPath = Join-Path $downloadDirectory ([IO.Path]::GetFileName($installerUri.AbsolutePath)) + Invoke-WebRequestWithRetry -Uri $installerUri -OutFile $installerPath -Operation 'NVDA installer download' + Assert-AuthenticodePublisher -Path $installerPath -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' + $process = Start-Process -FilePath $installerPath -ArgumentList @('--install-silent') -Wait -PassThru + Assert-InstallerExitCode -ProductName 'NVDA' -ExitCode $process.ExitCode + Remove-Item -LiteralPath $installerPath -Force +} function Install-GoogleChrome { $installerUri = 'https://dl.google.com/dl/chrome/install/googlechromestandaloneenterprise64.msi' @@ -28,7 +119,7 @@ function Install-GoogleChrome { # The Chrome Enterprise URL always points at the current stable MSI, so a # static checksum would become stale. Verify Google's code-signing identity - # instead of bypassing integrity checks with Chocolatey's --ignore-checksums. + # rather than bypassing integrity checks. $signature = Get-AuthenticodeSignature -FilePath $installerPath $publisher = $signature.SignerCertificate.Subject if ($signature.Status -ne 'Valid' -or $publisher -notmatch '(^|, )O=Google LLC(,|$)') { @@ -56,36 +147,6 @@ function Install-GoogleChrome { Remove-Item -Path $installerPath -Force } -function Install-OrUpgradePackage { - param( - [Parameter(Mandatory = $true)] - [string]$Package, - - [int]$MaxAttempts = 3 - ) - - for ($attempt = 1; $attempt -le $MaxAttempts; $attempt++) { - Write-Output "$logPrefix Installing/updating $Package (attempt $attempt of $MaxAttempts)..." - - # Do not discard Chocolatey's output: it contains the installer-specific - # error that is needed to diagnose failures from the SSM command log. - & $chocoPath upgrade $Package -y --no-progress --execution-timeout=1200 --ignore-detected-reboot - $exitCode = $LASTEXITCODE - - if ($exitCode -in $successfulExitCodes) { - return - } - - if ($attempt -lt $MaxAttempts) { - $retryDelay = 15 * $attempt - Write-Warning "$logPrefix choco upgrade $Package failed with exit code $exitCode; retrying in $retryDelay seconds." - Start-Sleep -Seconds $retryDelay - } - } - - throw "$logPrefix choco upgrade $Package failed after $MaxAttempts attempts (last exit code: $exitCode)" -} - function Find-GoogleChromeExecutable { $candidates = @( 'C:\Program Files\Google\Chrome\Application\chrome.exe' @@ -124,32 +185,46 @@ function Wait-GoogleChromeExecutable { return $null } -$packages = @('firefox', 'nvda') -foreach ($package in $packages) { - Install-OrUpgradePackage -Package $package -} - -Install-GoogleChrome - -Write-Output "$logPrefix Installed package versions:" -$chromeExecutable = Wait-GoogleChromeExecutable -if (-not $chromeExecutable) { - $installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log' - $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 -ErrorAction SilentlyContinue) -join [Environment]::NewLine - throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail" +function Write-InstalledSoftwareVersions { + param( + [Parameter(Mandatory = $true)] + [Collections.IDictionary]$SoftwareExecutables + ) + foreach ($software in $SoftwareExecutables.GetEnumerator()) { + if (-not (Test-Path $software.Value)) { + throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" + } + $versionInfo = (Get-Item $software.Value).VersionInfo + $version = if ($versionInfo.ProductVersion) { + $versionInfo.ProductVersion + } else { + $versionInfo.FileVersion + } + Write-Output "VERSION_$($software.Key)=$version" + } } -$softwareExecutables = [ordered]@{ - GOOGLECHROME = $chromeExecutable - FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' - NVDA = 'C:\Program Files (x86)\NVDA\nvda.exe' -} -foreach ($software in $softwareExecutables.GetEnumerator()) { - if (-not (Test-Path $software.Value)) { - throw "$logPrefix $($software.Key) executable was not found at $($software.Value)" +function Invoke-InstallSoftware { + Install-Firefox + Install-Nvda + Install-GoogleChrome + + Write-Output "$logPrefix Installed package versions:" + $chromeExecutable = Wait-GoogleChromeExecutable + if (-not $chromeExecutable) { + $installerLogPath = Join-Path $env:TEMP 'windows-a11y-installers\googlechrome-install.log' + $logTail = (Get-Content -LiteralPath $installerLogPath -Tail 40 ` + -ErrorAction SilentlyContinue) -join [Environment]::NewLine + throw "$logPrefix GOOGLECHROME executable was not found after waiting for the MSI installation to settle. MSI log: $installerLogPath`n$logTail" + } + $softwareExecutables = [ordered]@{ + GOOGLECHROME = $chromeExecutable + FIREFOX = 'C:\Program Files\Mozilla Firefox\firefox.exe' + NVDA = Find-NvdaExecutable } + Write-InstalledSoftwareVersions -SoftwareExecutables $softwareExecutables +} - $versionInfo = (Get-Item $software.Value).VersionInfo - $version = if ($versionInfo.ProductVersion) { $versionInfo.ProductVersion } else { $versionInfo.FileVersion } - Write-Output "VERSION_$($software.Key)=$version" +if (-not $SkipExecution) { + Invoke-InstallSoftware } diff --git a/scripts/windows-a11y/install-updates.ps1 b/scripts/windows-a11y/install-updates.ps1 index 417ac3b..68b892c 100644 --- a/scripts/windows-a11y/install-updates.ps1 +++ b/scripts/windows-a11y/install-updates.ps1 @@ -4,10 +4,108 @@ param() $ErrorActionPreference = 'Stop' $logPrefix = '[install-updates]' +function ConvertTo-HResultHex { + param( + [Parameter(Mandatory)] + [long]$HResult + ) + + $unsignedValue = $HResult -band 0xffffffffL + return '0x{0:X8}' -f $unsignedValue +} + +function Test-TransientWindowsUpdateHResult { + param( + [Parameter(Mandatory)] + [long]$HResult + ) + + $hResultHex = ConvertTo-HResultHex -HResult $HResult + return $hResultHex -in @( + '0x8007045B', # ERROR_SHUTDOWN_IN_PROGRESS + '0x8024001E', # WU_E_SERVICE_STOP + '0x80240016' # WU_E_INSTALL_NOT_ALLOWED + ) +} + +function Write-WindowsUpdateComExceptionDiagnostics { + param( + [Parameter(Mandatory)] + [string]$Operation, + + [Parameter(Mandatory)] + [System.Runtime.InteropServices.COMException]$Exception, + + [string]$Prefix = '[install-updates]' + ) + + $hResultHex = ConvertTo-HResultHex -HResult $Exception.HResult + Write-Host "$Prefix COM failure during $Operation; HRESULT: $hResultHex" + if (Test-TransientWindowsUpdateHResult -HResult $Exception.HResult) { + Write-Host 'TRANSIENT_WINDOWS_UPDATE_ERROR=true' + } +} + +function Invoke-WindowsUpdateComOperation { + param( + [Parameter(Mandatory)] + [string]$Operation, + + [Parameter(Mandatory)] + [scriptblock]$Action + ) + + try { + & $Action + } catch [System.Runtime.InteropServices.COMException] { + Write-WindowsUpdateComExceptionDiagnostics -Operation $Operation -Exception $_.Exception + throw + } +} + +function Write-InstallationDiagnostics { + param( + [Parameter(Mandatory)] + $InstallResult, + + [Parameter(Mandatory)] + $Updates, + + [string]$Prefix = '[install-updates]' + ) + + $aggregateHResult = ConvertTo-HResultHex -HResult $InstallResult.HResult + Write-Output "$Prefix Install result code: $($InstallResult.ResultCode); HRESULT: $aggregateHResult" + $hasTransientError = Test-TransientWindowsUpdateHResult -HResult $InstallResult.HResult + + for ($index = 0; $index -lt $Updates.Count; $index++) { + $update = $Updates[$index] + $updateResult = $InstallResult.GetUpdateResult($index) + $updateHResult = ConvertTo-HResultHex -HResult $updateResult.HResult + Write-Output ( + "$Prefix Update result: $($update.Title); code: $($updateResult.ResultCode); " + + "HRESULT: $updateHResult; reboot required: $($updateResult.RebootRequired)" + ) + if (Test-TransientWindowsUpdateHResult -HResult $updateResult.HResult) { + $hasTransientError = $true + } + } + + if ($hasTransientError) { + Write-Output 'TRANSIENT_WINDOWS_UPDATE_ERROR=true' + } +} + Write-Output "$logPrefix Searching for updates..." -$updateSession = New-Object -ComObject Microsoft.Update.Session -$updateSearcher = $updateSession.CreateUpdateSearcher() -$searchResult = $updateSearcher.Search("IsInstalled=0 and IsHidden=0") +$updateSession = Invoke-WindowsUpdateComOperation -Operation 'session creation' -Action { + New-Object -ComObject Microsoft.Update.Session +} +$updateSearcher = Invoke-WindowsUpdateComOperation -Operation 'searcher creation' -Action { + $updateSession.CreateUpdateSearcher() +} +$searchResult = Invoke-WindowsUpdateComOperation -Operation 'search' -Action { + $updateSearcher.Search("IsInstalled=0 and IsHidden=0") +} if ($searchResult.Updates.Count -eq 0) { Write-Output "$logPrefix No updates found." @@ -25,7 +123,9 @@ foreach ($update in $searchResult.Updates) { Write-Output "$logPrefix Downloading $($updatesToDownload.Count) update(s)..." $downloader = $updateSession.CreateUpdateDownloader() $downloader.Updates = $updatesToDownload -$downloadResult = $downloader.Download() +$downloadResult = Invoke-WindowsUpdateComOperation -Operation 'download' -Action { + $downloader.Download() +} if ($downloadResult.ResultCode -ne 2) { throw "$logPrefix Download failed with result code $($downloadResult.ResultCode)" } @@ -38,9 +138,11 @@ foreach ($update in $updatesToDownload) { Write-Output "$logPrefix Installing $($updatesToInstall.Count) update(s)..." $installer = $updateSession.CreateUpdateInstaller() $installer.Updates = $updatesToInstall -$installResult = $installer.Install() +$installResult = Invoke-WindowsUpdateComOperation -Operation 'install' -Action { + $installer.Install() +} -Write-Output "$logPrefix Install result code: $($installResult.ResultCode)" +Write-InstallationDiagnostics -InstallResult $installResult -Updates $updatesToInstall Write-Output "$logPrefix Reboot required: $($installResult.RebootRequired)" if ($installResult.ResultCode -ne 2 -and $installResult.ResultCode -ne 3) { diff --git a/scripts/windows-a11y/run-windows-updates.sh b/scripts/windows-a11y/run-windows-updates.sh new file mode 100644 index 0000000..61dcd05 --- /dev/null +++ b/scripts/windows-a11y/run-windows-updates.sh @@ -0,0 +1,79 @@ +#!/usr/bin/env bash +set -uo pipefail + +INSTANCE_ID="${1:?Usage: run-windows-updates.sh }" +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +SSM_RUN_SCRIPT="${SSM_RUN_SCRIPT:-${SCRIPT_DIR}/ssm-run.sh}" +TRANSIENT_RETRIES="${WINDOWS_UPDATE_TRANSIENT_RETRIES:-10}" +RETRY_DELAY_SECONDS="${WINDOWS_UPDATE_RETRY_DELAY_SECONDS:-60}" +SSM_READY_ATTEMPTS="${WINDOWS_UPDATE_SSM_READY_ATTEMPTS:-60}" +SSM_POLL_SECONDS="${WINDOWS_UPDATE_SSM_POLL_SECONDS:-10}" +: "${GITHUB_ENV:?GITHUB_ENV must point to the GitHub Actions environment file}" + +wait_for_ssm_online() { + local attempt + local ping_status + + for attempt in $(seq 1 "${SSM_READY_ATTEMPTS}"); do + ping_status=$(aws ssm describe-instance-information \ + --filters "Key=InstanceIds,Values=${INSTANCE_ID}" \ + --query 'InstanceInformationList[0].PingStatus' \ + --output text 2>/dev/null || true) + + if [[ "${ping_status}" == "Online" ]]; then + echo "SSM Agent is Online." + return 0 + fi + + echo "Waiting for SSM Agent to report Online (attempt ${attempt}/${SSM_READY_ATTEMPTS}; status: ${ping_status:-Unknown})..." + if (( attempt < SSM_READY_ATTEMPTS )); then + sleep "${SSM_POLL_SECONDS}" + fi + done + + echo "SSM Agent did not report Online after ${SSM_READY_ATTEMPTS} attempts." >&2 + return 1 +} + +for pass in $(seq 1 5); do + transient_retry=0 + while true; do + set +e + OUTPUT=$(bash "${SSM_RUN_SCRIPT}" "${INSTANCE_ID}" "${SCRIPT_DIR}/install-updates.ps1" 3600) + SSM_STATUS=$? + set -e + + printf '%s\n' "${OUTPUT}" + if (( SSM_STATUS == 0 )); then + break + fi + + if ! grep -q '^TRANSIENT_WINDOWS_UPDATE_ERROR=true$' <<< "${OUTPUT}"; then + exit "${SSM_STATUS}" + fi + + if (( transient_retry >= TRANSIENT_RETRIES )); then + echo "Transient Windows Update retry limit reached for pass ${pass}." >&2 + exit "${SSM_STATUS}" + fi + + transient_retry=$((transient_retry + 1)) + echo "Transient Windows Update failure; retrying pass ${pass} (retry ${transient_retry}/${TRANSIENT_RETRIES})..." + sleep "${RETRY_DELAY_SECONDS}" + done + + if grep -q "No updates found." <<< "${OUTPUT}"; then + echo "No further updates." + break + fi + + if grep -q "REBOOT_REQUIRED=true" <<< "${OUTPUT}"; then + echo "Rebooting instance for updates (pass ${pass})..." + aws ec2 reboot-instances --instance-ids "${INSTANCE_ID}" + sleep 30 + aws ec2 wait instance-status-ok --instance-ids "${INSTANCE_ID}" + wait_for_ssm_online + fi +done + +echo "WINDOWS_UPDATE_DATE=$(date -u +%Y-%m-%d)" >> "${GITHUB_ENV}" diff --git a/scripts/windows-a11y/tests/create-ami.Tests.ps1 b/scripts/windows-a11y/tests/create-ami.Tests.ps1 new file mode 100644 index 0000000..91072b8 --- /dev/null +++ b/scripts/windows-a11y/tests/create-ami.Tests.ps1 @@ -0,0 +1,106 @@ +Describe 'AMI creation polling' { + BeforeEach { + $script:runnerPath = Join-Path $PSScriptRoot '..\create-ami.sh' + $script:binDirectory = Join-Path $TestDrive 'bin' + $script:stateFile = Join-Path $TestDrive 'describe-count' + $script:githubOutput = Join-Path $TestDrive 'github-output' + $script:originalPath = $env:PATH + + New-Item -ItemType Directory -Path $script:binDirectory -Force | Out-Null + Remove-Item -LiteralPath $script:stateFile -Force -ErrorAction SilentlyContinue + Set-Content -LiteralPath $script:githubOutput -Value '' -NoNewline -Force + + $awsStub = Join-Path $script:binDirectory 'aws' + Set-Content -LiteralPath $awsStub -Value @' +#!/usr/bin/env bash +set -euo pipefail + +if [[ "$1 $2" == "ec2 create-image" ]]; then + echo "ami-test123" + exit 0 +fi + +if [[ "$1 $2" == "ec2 describe-images" ]]; then + count=0 + if [[ -f "${AWS_STUB_STATE_FILE}" ]]; then + count=$(<"${AWS_STUB_STATE_FILE}") + fi + count=$((count + 1)) + printf '%s' "${count}" > "${AWS_STUB_STATE_FILE}" + + if [[ "${AWS_STUB_MODE}" == "not-found-then-available" && "${count}" == "1" ]]; then + echo "An error occurred (InvalidAMIID.NotFound) when calling DescribeImages" >&2 + exit 255 + elif [[ "${AWS_STUB_MODE}" == "failed" ]]; then + printf '%s\n' '{"Images":[{"ImageId":"ami-test123","State":"failed","StateReason":{"Message":"snapshot failed"}}]}' + elif (( count >= AWS_STUB_AVAILABLE_AFTER )); then + printf '%s\n' '{"Images":[{"ImageId":"ami-test123","State":"available"}]}' + else + printf '%s\n' '{"Images":[{"ImageId":"ami-test123","State":"pending"}]}' + fi + exit 0 +fi + +echo "unexpected aws invocation: $*" >&2 +exit 64 +'@ + & chmod +x $awsStub + + $env:PATH = "$script:binDirectory$([IO.Path]::PathSeparator)$script:originalPath" + $env:GITHUB_OUTPUT = $script:githubOutput + $env:AWS_STUB_STATE_FILE = $script:stateFile + $env:AMI_POLL_INTERVAL_SECONDS = '0' + Remove-Item Env:AMI_MAX_ATTEMPTS -ErrorAction SilentlyContinue + } + + AfterEach { + $env:PATH = $script:originalPath + Remove-Item Env:GITHUB_OUTPUT -ErrorAction SilentlyContinue + Remove-Item Env:AWS_STUB_STATE_FILE -ErrorAction SilentlyContinue + Remove-Item Env:AWS_STUB_MODE -ErrorAction SilentlyContinue + Remove-Item Env:AWS_STUB_AVAILABLE_AFTER -ErrorAction SilentlyContinue + Remove-Item Env:AMI_POLL_INTERVAL_SECONDS -ErrorAction SilentlyContinue + Remove-Item Env:AMI_MAX_ATTEMPTS -ErrorAction SilentlyContinue + } + + It 'continues polling beyond forty checks until the AMI is available' { + $env:AWS_STUB_MODE = 'available' + $env:AWS_STUB_AVAILABLE_AFTER = '41' + + $output = @(& bash $script:runnerPath 'i-test' 'windows-a11y-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + $output | Should -Contain '[create-ami] Created AMI ami-test123.' + $output | Should -Contain '[create-ami] AMI ami-test123 state: pending (attempt 40 of 120).' + $output | Should -Contain '[create-ami] AMI ami-test123 state: available (attempt 41 of 120).' + (Get-Content -LiteralPath $script:stateFile -Raw) | Should -BeExactly '41' + (Get-Content -LiteralPath $script:githubOutput -Raw).Trim() | + Should -BeExactly 'ami_id=ami-test123' + } + + It 'retries when a newly created AMI is not visible yet' { + $env:AWS_STUB_MODE = 'not-found-then-available' + $env:AWS_STUB_AVAILABLE_AFTER = '2' + + $output = @(& bash $script:runnerPath 'i-test' 'windows-a11y-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + $output | Should -Contain '[create-ami] AMI ami-test123 is not visible yet (attempt 1 of 120).' + $output | Should -Contain '[create-ami] AMI ami-test123 state: available (attempt 2 of 120).' + (Get-Content -LiteralPath $script:stateFile -Raw) | Should -BeExactly '2' + } + + It 'fails immediately and reports StateReason when the AMI enters failed state' { + $env:AWS_STUB_MODE = 'failed' + $env:AWS_STUB_AVAILABLE_AFTER = '999' + + $output = @(& bash $script:runnerPath 'i-test' 'windows-a11y-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 1 + $output | Should -Contain '[create-ami] AMI ami-test123 state: failed (attempt 1 of 120); reason: snapshot failed.' + (Get-Content -LiteralPath $script:stateFile -Raw) | Should -BeExactly '1' + } +} diff --git a/scripts/windows-a11y/tests/install-software.Tests.ps1 b/scripts/windows-a11y/tests/install-software.Tests.ps1 new file mode 100644 index 0000000..9226f53 --- /dev/null +++ b/scripts/windows-a11y/tests/install-software.Tests.ps1 @@ -0,0 +1,227 @@ +BeforeAll { + if (-not (Get-Command Get-AuthenticodeSignature -ErrorAction SilentlyContinue)) { + function global:Get-AuthenticodeSignature { + param([string]$FilePath) + throw "Get-AuthenticodeSignature must be mocked on non-Windows hosts: $FilePath" + } + } + . (Join-Path $PSScriptRoot '..\install-software.ps1') -SkipExecution +} + +Describe 'official stable installer resolution' { + It 'returns the Mozilla latest stable zh-TW win64 endpoint' { + (Get-FirefoxInstallerUri).AbsoluteUri | Should -BeExactly ` + 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' + } + + It 'accepts one numeric NVDA stable patch release' { + $content = 'nvda_2026.1.1.exe' + $uri = Get-NvdaStableInstallerUri -Content $content + $uri.AbsoluteUri | Should -BeExactly ` + 'https://download.nvaccess.org/releases/stable/nvda_2026.1.1.exe' + } + + It 'rejects NVDA beta and release-candidate installers' { + $content = @' +beta +rc +'@ + { Get-NvdaStableInstallerUri -Content $content } | + Should -Throw '*exactly one numeric stable NVDA installer*' + } + + It 'rejects an ambiguous stable listing' { + $content = @' +first +second +'@ + { Get-NvdaStableInstallerUri -Content $content } | + Should -Throw '*found 2*' + } +} + +Describe 'publisher and exit-code validation' { + It 'accepts a valid expected publisher' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=Mozilla Corporation, O=Mozilla Corporation, C=US' + } + } + } + { Assert-AuthenticodePublisher -Path 'firefox.exe' -ProductName 'Firefox' ` + -PublisherPattern '(^|, )O=Mozilla Corporation(,|$)' } | + Should -Not -Throw + } + + It 'rejects an unexpected publisher even when the signature is valid' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'Valid' + SignerCertificate = [pscustomobject]@{ Subject = 'CN=Unexpected, O=Unexpected, C=US' } + } + } + { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | + Should -Throw '*signature verification failed*' + } + + It 'rejects an invalid signature from the expected publisher' { + Mock Get-AuthenticodeSignature { + [pscustomobject]@{ + Status = 'HashMismatch' + SignerCertificate = [pscustomobject]@{ + Subject = 'CN=NV Access Limited, O=NV Access Limited, C=AU' + } + } + } + { Assert-AuthenticodePublisher -Path 'nvda.exe' -ProductName 'NVDA' ` + -PublisherPattern '(^|, )O=NV Access Limited(,|$)' } | + Should -Throw '*signature verification failed*' + } + + It 'rejects a nonzero executable installer exit code' { + { Assert-InstallerExitCode -ProductName 'Firefox' -ExitCode 1 } | + Should -Throw '*exit code 1*' + } +} + +Describe 'bounded official download retries' { + It 'rejects download retry counts above three' { + Mock Invoke-WebRequest + + { Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" -MaxAttempts 4 } | + Should -Throw '*MaxAttempts*' + } + + It 'retries twice and succeeds on the third attempt' { + $script:attempt = 0 + Mock Invoke-WebRequest { + $script:attempt++ + if ($script:attempt -lt 3) { throw 'temporary failure' } + } + Mock Start-Sleep + + Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" + + Should -Invoke Invoke-WebRequest -Times 3 -Exactly + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 15 } + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } + } + + It 'fails after three requests with contextual retry warnings' { + Mock Invoke-WebRequest { throw 'temporary failure' } + Mock Start-Sleep + Mock Write-Warning + + { Invoke-WebRequestWithRetry -Uri 'https://download.mozilla.org/example.exe' ` + -OutFile "$TestDrive\example.exe" -Operation 'Firefox installer download' } | + Should -Throw '*failed after 3 attempts*' + + Should -Invoke Invoke-WebRequest -Times 3 -Exactly + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 15 } + Should -Invoke Start-Sleep -Times 1 -ParameterFilter { $Seconds -eq 30 } + Should -Invoke Start-Sleep -Times 2 -Exactly + Should -Invoke Write-Warning -Times 1 -ParameterFilter { + $Message -like '*Firefox installer download*attempt 1 of 3*15*' + } + Should -Invoke Write-Warning -Times 1 -ParameterFilter { + $Message -like '*Firefox installer download*attempt 2 of 3*30*' + } + } +} + +Describe 'NVDA executable resolution' { + It 'prefers the current 64-bit NVDA executable path' { + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files\NVDA\nvda.exe' + } + + It 'falls back to the legacy x86 NVDA executable path' { + Mock Test-Path { $false } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files (x86)\NVDA\nvda.exe' + } +} + +Describe 'official product installers' { + BeforeEach { + $env:TEMP = $TestDrive + Mock New-Item + Mock Remove-Item + Mock Invoke-WebRequestWithRetry + Mock Assert-AuthenticodePublisher + Mock Start-Process { [pscustomobject]@{ ExitCode = 0 } } + Mock Assert-InstallerExitCode + } + + It 'downloads and silently installs Firefox zh-TW win64' { + Install-Firefox + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.mozilla.org/?product=firefox-latest-ssl&os=win64&lang=zh-TW' -and + $Operation -eq 'Firefox installer download' + } + Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { + $ProductName -eq 'Firefox' -and + $PublisherPattern -eq '(^|, )O=Mozilla Corporation(,|$)' + } + Should -Invoke Start-Process -Times 1 -ParameterFilter { + $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '/S' -and $Wait -and $PassThru + } + } + + It 'resolves and silently installs the official stable NVDA build' { + Mock Invoke-WebRequestWithRetry { + [pscustomobject]@{ Content = 'download' } + } -ParameterFilter { -not $OutFile } + + Install-Nvda + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/' -and -not $OutFile -and + $Operation -eq 'NVDA stable listing download' + } + Should -Invoke Invoke-WebRequestWithRetry -Times 1 -ParameterFilter { + $Uri.AbsoluteUri -eq 'https://download.nvaccess.org/releases/stable/nvda_2026.1.1.exe' -and + $Operation -eq 'NVDA installer download' + } + Should -Invoke Assert-AuthenticodePublisher -Times 1 -ParameterFilter { + $ProductName -eq 'NVDA' -and + $PublisherPattern -eq '(^|, )O=NV Access Limited(,|$)' + } + Should -Invoke Start-Process -Times 1 -ParameterFilter { + $ArgumentList.Count -eq 1 -and $ArgumentList[0] -eq '--install-silent' -and $Wait -and $PassThru + } + } +} + +Describe 'workflow version output contract' { + It 'emits the three existing VERSION keys' { + Mock Test-Path { $true } + Mock Get-Item { + [pscustomobject]@{ + VersionInfo = [pscustomobject]@{ + ProductVersion = '1.2.3' + FileVersion = '1.2.3.0' + } + } + } + $executables = [ordered]@{ + GOOGLECHROME = 'C:\Google\chrome.exe' + FIREFOX = 'C:\Mozilla Firefox\firefox.exe' + NVDA = 'C:\NVDA\nvda.exe' + } + + $output = @(Write-InstalledSoftwareVersions -SoftwareExecutables $executables) + + $output | Should -Contain 'VERSION_GOOGLECHROME=1.2.3' + $output | Should -Contain 'VERSION_FIREFOX=1.2.3' + $output | Should -Contain 'VERSION_NVDA=1.2.3' + @($output | Where-Object { $_ -like 'VERSION_*=*' }).Count | Should -Be 3 + } +} diff --git a/scripts/windows-a11y/tests/install-updates.Tests.ps1 b/scripts/windows-a11y/tests/install-updates.Tests.ps1 new file mode 100644 index 0000000..db08999 --- /dev/null +++ b/scripts/windows-a11y/tests/install-updates.Tests.ps1 @@ -0,0 +1,117 @@ +Describe 'Windows Update installation diagnostics' { + BeforeAll { + $scriptPath = Join-Path $PSScriptRoot '..\install-updates.ps1' + $tokens = $null + $errors = $null + $ast = [System.Management.Automation.Language.Parser]::ParseFile( + $scriptPath, + [ref]$tokens, + [ref]$errors + ) + $functionAsts = $ast.FindAll({ + param($node) + $node -is [System.Management.Automation.Language.FunctionDefinitionAst] -and + $node.Name -in @( + 'ConvertTo-HResultHex', + 'Test-TransientWindowsUpdateHResult', + 'Write-WindowsUpdateComExceptionDiagnostics', + 'Invoke-WindowsUpdateComOperation', + 'Write-InstallationDiagnostics' + ) + }, $true) + + @($functionAsts).Count | Should -Be 5 + foreach ($functionAst in $functionAsts) { + Invoke-Expression $functionAst.Extent.Text + } + } + + It 'reports aggregate and per-update result codes and HRESULT values' { + $firstResult = [pscustomobject]@{ + ResultCode = 2 + HResult = 0 + RebootRequired = $false + } + $secondResult = [pscustomobject]@{ + ResultCode = 4 + HResult = -2145124329 + RebootRequired = $true + } + $installResult = [pscustomobject]@{ + ResultCode = 4 + HResult = -2145124329 + RebootRequired = $true + UpdateResults = @($firstResult, $secondResult) + } + $installResult | Add-Member -MemberType ScriptMethod -Name GetUpdateResult -Value { + param($index) + $this.UpdateResults[$index] + } + $updates = @( + [pscustomobject]@{ Title = 'Successful update' }, + [pscustomobject]@{ Title = 'Failed update' } + ) + + $output = @(Write-InstallationDiagnostics -InstallResult $installResult -Updates $updates) + + $output | Should -Contain '[install-updates] Install result code: 4; HRESULT: 0x80240017' + $output | Should -Contain '[install-updates] Update result: Successful update; code: 2; HRESULT: 0x00000000; reboot required: False' + $output | Should -Contain '[install-updates] Update result: Failed update; code: 4; HRESULT: 0x80240017; reboot required: True' + } + + It 'marks shutdown-in-progress COM exceptions as transient using a numeric HRESULT' { + $exception = [System.Runtime.InteropServices.COMException]::new( + 'A system shutdown is in progress.', + [int]0x8007045B + ) + + $output = @(& { + Write-WindowsUpdateComExceptionDiagnostics -Operation 'search' -Exception $exception + } 6>&1).ForEach({ $_.ToString() }) + + $output | Should -Contain '[install-updates] COM failure during search; HRESULT: 0x8007045B' + $output | Should -Contain 'TRANSIENT_WINDOWS_UPDATE_ERROR=true' + } + + It 'emits transient diagnostics outside an operation result assignment' { + $output = @(& { + try { + $ignored = Invoke-WindowsUpdateComOperation -Operation 'search' -Action { + throw [System.Runtime.InteropServices.COMException]::new( + 'A system shutdown is in progress.', + [int]0x8007045B + ) + } + } catch { + # The production script lets the original COM exception terminate the SSM command. + } + } 6>&1) + + @($output.ForEach({ $_.ToString() })) | Should -Contain '[install-updates] COM failure during search; HRESULT: 0x8007045B' + @($output.ForEach({ $_.ToString() })) | Should -Contain 'TRANSIENT_WINDOWS_UPDATE_ERROR=true' + } + + It 'marks a transient per-update HRESULT so the runner can retry the pass' { + $updateResult = [pscustomobject]@{ + ResultCode = 4 + HResult = [int]0x80240016 + RebootRequired = $true + } + $installResult = [pscustomobject]@{ + ResultCode = 4 + HResult = [int]0x80240022 + RebootRequired = $true + UpdateResults = @($updateResult) + } + $installResult | Add-Member -MemberType ScriptMethod -Name GetUpdateResult -Value { + param($index) + $this.UpdateResults[$index] + } + + $output = @(Write-InstallationDiagnostics ` + -InstallResult $installResult ` + -Updates @([pscustomobject]@{ Title = 'Busy update' })) + + $output | Should -Contain 'TRANSIENT_WINDOWS_UPDATE_ERROR=true' + } +} diff --git a/scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 b/scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 new file mode 100644 index 0000000..1df3039 --- /dev/null +++ b/scripts/windows-a11y/tests/run-windows-updates.Tests.ps1 @@ -0,0 +1,156 @@ +Describe 'Windows Update workflow runner' { + BeforeEach { + $script:originalPath = $env:PATH + $script:runnerPath = Join-Path $PSScriptRoot '..\run-windows-updates.sh' + $script:ssmStub = Join-Path $TestDrive 'ssm-stub.sh' + $script:githubEnv = Join-Path $TestDrive 'github-env' + Set-Content -LiteralPath $script:ssmStub -Value @' +#!/usr/bin/env bash +echo "[install-updates] Update result: Failed update; code: 4; HRESULT: 0x80240017" +exit 7 +'@ + Set-Content -LiteralPath $script:githubEnv -Value '' -NoNewline + } + + It 'prints captured SSM stdout and preserves a failed exit status' { + $env:SSM_RUN_SCRIPT = $script:ssmStub + $env:GITHUB_ENV = $script:githubEnv + + $output = @(& bash $script:runnerPath 'i-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 7 + $output | Should -Contain '[install-updates] Update result: Failed update; code: 4; HRESULT: 0x80240017' + (Get-Content -LiteralPath $script:githubEnv -Raw) | Should -BeNullOrEmpty + } + + It 'retries a transient Windows Update failure and records success after recovery' { + $stateFile = Join-Path $TestDrive 'retry-attempts' + Set-Content -LiteralPath $script:ssmStub -Value @' +#!/usr/bin/env bash +attempt=0 +if [[ -f "${SSM_STUB_STATE_FILE}" ]]; then + attempt=$(cat "${SSM_STUB_STATE_FILE}") +fi +attempt=$((attempt + 1)) +printf '%s' "${attempt}" > "${SSM_STUB_STATE_FILE}" +if (( attempt == 1 )); then + echo "[install-updates] COM failure during search; HRESULT: 0x8007045B" + echo "TRANSIENT_WINDOWS_UPDATE_ERROR=true" + exit 7 +fi +echo "[install-updates] No updates found." +echo "REBOOT_REQUIRED=false" +'@ + $env:SSM_RUN_SCRIPT = $script:ssmStub + $env:SSM_STUB_STATE_FILE = $stateFile + $env:WINDOWS_UPDATE_RETRY_DELAY_SECONDS = '0' + $env:GITHUB_ENV = $script:githubEnv + + $output = @(& bash $script:runnerPath 'i-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + (Get-Content -LiteralPath $stateFile -Raw) | Should -Be '2' + $output | Should -Contain 'Transient Windows Update failure; retrying pass 1 (retry 1/10)...' + (Get-Content -LiteralPath $script:githubEnv -Raw) | Should -Match '^WINDOWS_UPDATE_DATE=\d{4}-\d{2}-\d{2}\s*$' + } + + It 'stops after the configured transient retry limit without recording success' { + $stateFile = Join-Path $TestDrive 'limit-attempts' + Set-Content -LiteralPath $script:ssmStub -Value @' +#!/usr/bin/env bash +attempt=0 +if [[ -f "${SSM_STUB_STATE_FILE}" ]]; then + attempt=$(cat "${SSM_STUB_STATE_FILE}") +fi +attempt=$((attempt + 1)) +printf '%s' "${attempt}" > "${SSM_STUB_STATE_FILE}" +echo "[install-updates] COM failure during search; HRESULT: 0x8024001E" +echo "TRANSIENT_WINDOWS_UPDATE_ERROR=true" +exit 9 +'@ + $env:SSM_RUN_SCRIPT = $script:ssmStub + $env:SSM_STUB_STATE_FILE = $stateFile + $env:WINDOWS_UPDATE_TRANSIENT_RETRIES = '2' + $env:WINDOWS_UPDATE_RETRY_DELAY_SECONDS = '0' + $env:GITHUB_ENV = $script:githubEnv + + $output = @(& bash $script:runnerPath 'i-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 9 + (Get-Content -LiteralPath $stateFile -Raw) | Should -Be '3' + ($output -join "`n") | Should -Match 'Transient Windows Update retry limit reached for pass 1\.' + (Get-Content -LiteralPath $script:githubEnv -Raw) | Should -BeNullOrEmpty + } + + It 'waits for SSM to report Online after reboot before starting the next pass' { + $binDir = Join-Path $TestDrive 'bin' + $ssmStateFile = Join-Path $TestDrive 'reboot-ssm-attempts' + $awsStateFile = Join-Path $TestDrive 'ssm-readiness-attempts' + New-Item -ItemType Directory -Path $binDir | Out-Null + Set-Content -LiteralPath $script:ssmStub -Value @' +#!/usr/bin/env bash +attempt=0 +if [[ -f "${SSM_STUB_STATE_FILE}" ]]; then + attempt=$(cat "${SSM_STUB_STATE_FILE}") +fi +attempt=$((attempt + 1)) +printf '%s' "${attempt}" > "${SSM_STUB_STATE_FILE}" +if (( attempt == 1 )); then + echo "[install-updates] Install result code: 2; HRESULT: 0x00000000" + echo "REBOOT_REQUIRED=true" +else + echo "[install-updates] No updates found." + echo "REBOOT_REQUIRED=false" +fi +'@ + Set-Content -LiteralPath (Join-Path $binDir 'aws') -Value @' +#!/usr/bin/env bash +if [[ "$1 $2" == "ssm describe-instance-information" ]]; then + attempt=0 + if [[ -f "${AWS_STUB_STATE_FILE}" ]]; then + attempt=$(cat "${AWS_STUB_STATE_FILE}") + fi + attempt=$((attempt + 1)) + printf '%s' "${attempt}" > "${AWS_STUB_STATE_FILE}" + if (( attempt == 1 )); then + echo "ConnectionLost" + else + echo "Online" + fi +fi +'@ + Set-Content -LiteralPath (Join-Path $binDir 'sleep') -Value @' +#!/usr/bin/env bash +exit 0 +'@ + & chmod +x (Join-Path $binDir 'aws') (Join-Path $binDir 'sleep') + $env:PATH = "$binDir$([IO.Path]::PathSeparator)$($env:PATH)" + $env:SSM_RUN_SCRIPT = $script:ssmStub + $env:SSM_STUB_STATE_FILE = $ssmStateFile + $env:AWS_STUB_STATE_FILE = $awsStateFile + $env:WINDOWS_UPDATE_SSM_POLL_SECONDS = '0' + $env:GITHUB_ENV = $script:githubEnv + + $output = @(& bash $script:runnerPath 'i-test' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + (Get-Content -LiteralPath $ssmStateFile -Raw) | Should -Be '2' + (Get-Content -LiteralPath $awsStateFile -Raw) | Should -Be '2' + $output | Should -Contain 'Waiting for SSM Agent to report Online (attempt 1/60; status: ConnectionLost)...' + } + + AfterEach { + $env:PATH = $script:originalPath + Remove-Item Env:SSM_RUN_SCRIPT -ErrorAction Ignore + Remove-Item Env:SSM_STUB_STATE_FILE -ErrorAction Ignore + Remove-Item Env:AWS_STUB_STATE_FILE -ErrorAction Ignore + Remove-Item Env:WINDOWS_UPDATE_TRANSIENT_RETRIES -ErrorAction Ignore + Remove-Item Env:WINDOWS_UPDATE_RETRY_DELAY_SECONDS -ErrorAction Ignore + Remove-Item Env:WINDOWS_UPDATE_SSM_POLL_SECONDS -ErrorAction Ignore + Remove-Item Env:GITHUB_ENV -ErrorAction Ignore + } +} diff --git a/scripts/windows-a11y/tests/validate-stack-operation.Tests.ps1 b/scripts/windows-a11y/tests/validate-stack-operation.Tests.ps1 new file mode 100644 index 0000000..4a62db5 --- /dev/null +++ b/scripts/windows-a11y/tests/validate-stack-operation.Tests.ps1 @@ -0,0 +1,75 @@ +Describe 'Windows A11y stack operation validation' { + BeforeAll { + $script:validatorPath = Join-Path $PSScriptRoot '..\validate-stack-operation.sh' + } + + It 'builds the prefixed stack name for a launch request' { + $output = @(& bash $script:validatorPath 'launch' 'anson-test' '' '2026-08-15' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + $output | Should -Be @('windows-a11y-anson-test') + } + + It 'requires an AMI name for a launch request' { + $output = @(& bash $script:validatorPath 'launch' 'anson-test' '' '' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 1 + ($output -join "`n") | Should -Match 'AMI name is required when action is launch\.' + } + + It 'accepts deletion only when the full prefixed stack name is confirmed' { + $output = @(& bash $script:validatorPath 'delete' 'anson-test' 'windows-a11y-anson-test' '' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 0 + $output | Should -Be @('windows-a11y-anson-test') + } + + It 'rejects deletion when the confirmation does not match the full stack name' { + $output = @(& bash $script:validatorPath 'delete' 'anson-test' 'anson-test' '' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 1 + ($output -join "`n") | Should -Match 'enter the full stack name exactly: windows-a11y-anson-test' + } + + It 'rejects a suffix that already includes the managed prefix' { + $output = @(& bash $script:validatorPath 'launch' 'windows-a11y-anson-test' '' '2026-08-15' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 1 + ($output -join "`n") | Should -Match 'Enter only the suffix, without the windows-a11y- prefix\.' + } + + It 'rejects suffixes outside the lowercase alphanumeric and hyphen convention' { + $invalidSuffixes = @('Anson', 'anson_test', '-anson', 'anson-', 'anson test') + + foreach ($suffix in $invalidSuffixes) { + $output = @(& bash $script:validatorPath 'launch' $suffix '' '2026-08-15' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 1 -Because "'$suffix' is not a valid stack suffix" + ($output -join "`n") | Should -Match 'lowercase letters, numbers, and internal hyphens' + } + } + + It 'rejects a suffix that would exceed the CloudFormation stack name limit' { + $tooLongSuffix = 'a' * 116 + + $output = @(& bash $script:validatorPath 'launch' $tooLongSuffix '' '2026-08-15' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 1 + ($output -join "`n") | Should -Match '115 characters or fewer' + } + + It 'rejects unsupported actions' { + $output = @(& bash $script:validatorPath 'replace' 'anson-test' '' '2026-08-15' 2>&1) + $exitCode = $LASTEXITCODE + + $exitCode | Should -Be 1 + ($output -join "`n") | Should -Match 'Action must be launch or delete\.' + } +} diff --git a/scripts/windows-a11y/tests/verify-environment.Tests.ps1 b/scripts/windows-a11y/tests/verify-environment.Tests.ps1 new file mode 100644 index 0000000..4cf8a87 --- /dev/null +++ b/scripts/windows-a11y/tests/verify-environment.Tests.ps1 @@ -0,0 +1,19 @@ +BeforeAll { + . (Join-Path $PSScriptRoot '..\verify-environment.ps1') -SkipExecution +} + +Describe 'NVDA verification executable resolution' { + It 'prefers the current 64-bit NVDA executable path' { + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files\NVDA\nvda.exe' + } + + It 'falls back to the legacy x86 NVDA executable path' { + Mock Test-Path { $false } -ParameterFilter { $LiteralPath -eq 'C:\Program Files\NVDA\nvda.exe' } + Mock Test-Path { $true } -ParameterFilter { $LiteralPath -eq 'C:\Program Files (x86)\NVDA\nvda.exe' } + + Find-NvdaExecutable | Should -BeExactly 'C:\Program Files (x86)\NVDA\nvda.exe' + } +} diff --git a/scripts/windows-a11y/validate-stack-operation.sh b/scripts/windows-a11y/validate-stack-operation.sh new file mode 100644 index 0000000..5be0822 --- /dev/null +++ b/scripts/windows-a11y/validate-stack-operation.sh @@ -0,0 +1,42 @@ +#!/usr/bin/env bash +set -euo pipefail + +ACTION="${1:-}" +STACK_SUFFIX="${2:-}" +CONFIRM_STACK_NAME="${3:-}" +AMI_NAME="${4:-}" +STACK_PREFIX="windows-a11y-" +MAX_SUFFIX_LENGTH=115 + +fail() { + echo "[stack-operation] $1" >&2 + exit 1 +} + +if [[ "${ACTION}" != "launch" && "${ACTION}" != "delete" ]]; then + fail 'Action must be launch or delete.' +fi + +if [[ "${STACK_SUFFIX}" == "${STACK_PREFIX}"* ]]; then + fail "Enter only the suffix, without the ${STACK_PREFIX} prefix." +fi + +if (( ${#STACK_SUFFIX} > MAX_SUFFIX_LENGTH )); then + fail "Stack suffix must be ${MAX_SUFFIX_LENGTH} characters or fewer." +fi + +if [[ ! "${STACK_SUFFIX}" =~ ^[a-z0-9]([a-z0-9-]*[a-z0-9])?$ ]]; then + fail 'Stack suffix must use lowercase letters, numbers, and internal hyphens only.' +fi + +STACK_NAME="${STACK_PREFIX}${STACK_SUFFIX}" + +if [[ "${ACTION}" == "launch" && -z "${AMI_NAME}" ]]; then + fail 'AMI name is required when action is launch.' +fi + +if [[ "${ACTION}" == "delete" && "${CONFIRM_STACK_NAME}" != "${STACK_NAME}" ]]; then + fail "To delete this stack, enter the full stack name exactly: ${STACK_NAME}" +fi + +printf '%s\n' "${STACK_NAME}" diff --git a/scripts/windows-a11y/verify-environment.ps1 b/scripts/windows-a11y/verify-environment.ps1 index 28e2f91..7ae6ec9 100644 --- a/scripts/windows-a11y/verify-environment.ps1 +++ b/scripts/windows-a11y/verify-environment.ps1 @@ -1,5 +1,5 @@ [CmdletBinding()] -param() +param([switch]$SkipExecution) $ErrorActionPreference = 'Stop' @@ -25,32 +25,48 @@ function Find-GoogleChromeExecutable { return $candidates | Select-Object -Unique | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 } -$results = [ordered]@{} +function Find-NvdaExecutable { + $candidates = @( + 'C:\Program Files\NVDA\nvda.exe' + 'C:\Program Files (x86)\NVDA\nvda.exe' + ) + + return $candidates | Where-Object { Test-Path -LiteralPath $_ } | Select-Object -First 1 +} -$results.ChromePath = Find-GoogleChromeExecutable -$results.ChromeInstalled = [bool]$results.ChromePath -$results.FirefoxInstalled = Test-Path 'C:\Program Files\Mozilla Firefox\firefox.exe' -$results.NvdaInstalled = Test-Path 'C:\Program Files (x86)\NVDA\nvda.exe' +function Invoke-EnvironmentVerification { + $results = [ordered]@{} -$rdpValue = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections').fDenyTSConnections -$results.RdpEnabled = ($rdpValue -eq 0) + $results.ChromePath = Find-GoogleChromeExecutable + $results.ChromeInstalled = [bool]$results.ChromePath + $results.FirefoxInstalled = Test-Path 'C:\Program Files\Mozilla Firefox\firefox.exe' + $nvdaPath = Find-NvdaExecutable + $results.NvdaInstalled = [bool]$nvdaPath -$results.CoseeingIsAdmin = [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'coseeing' -ErrorAction SilentlyContinue) -$results.UserIsNotAdmin = -not [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'user' -ErrorAction SilentlyContinue) -$results.UserAccountExists = [bool](Get-LocalUser -Name 'user' -ErrorAction SilentlyContinue) -$results.BaseInstalledUiCulture = [System.Globalization.CultureInfo]::InstalledUICulture.Name -$results.DisplayLanguage = Get-SystemPreferredUILanguage -$results.SystemLocale = (Get-WinSystemLocale).Name -$results.DisplayLanguageIsTraditionalChinese = ($results.DisplayLanguage -in @('zh-TW', 'zh-Hant-TW')) -$results.SystemLocaleIsTraditionalChinese = ($results.SystemLocale -eq 'zh-TW') + $rdpValue = (Get-ItemProperty -Path 'HKLM:\System\CurrentControlSet\Control\Terminal Server' -Name 'fDenyTSConnections').fDenyTSConnections + $results.RdpEnabled = ($rdpValue -eq 0) -$checks = @('ChromeInstalled','FirefoxInstalled','NvdaInstalled','RdpEnabled','CoseeingIsAdmin','UserIsNotAdmin','UserAccountExists','DisplayLanguageIsTraditionalChinese','SystemLocaleIsTraditionalChinese') -$allPassed = -not ($checks | Where-Object { $results[$_] -ne $true }) -$results.AllChecksPassed = $allPassed + $results.CoseeingIsAdmin = [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'coseeing' -ErrorAction SilentlyContinue) + $results.UserIsNotAdmin = -not [bool](Get-LocalGroupMember -Group 'Administrators' -Member 'user' -ErrorAction SilentlyContinue) + $results.UserAccountExists = [bool](Get-LocalUser -Name 'user' -ErrorAction SilentlyContinue) + $results.BaseInstalledUiCulture = [System.Globalization.CultureInfo]::InstalledUICulture.Name + $results.DisplayLanguage = Get-SystemPreferredUILanguage + $results.SystemLocale = (Get-WinSystemLocale).Name + $results.DisplayLanguageIsTraditionalChinese = ($results.DisplayLanguage -in @('zh-TW', 'zh-Hant-TW')) + $results.SystemLocaleIsTraditionalChinese = ($results.SystemLocale -eq 'zh-TW') -$json = $results | ConvertTo-Json -Compress -Write-Output "VERIFY_RESULT_JSON=$json" + $checks = @('ChromeInstalled','FirefoxInstalled','NvdaInstalled','RdpEnabled','CoseeingIsAdmin','UserIsNotAdmin','UserAccountExists','DisplayLanguageIsTraditionalChinese','SystemLocaleIsTraditionalChinese') + $allPassed = -not ($checks | Where-Object { $results[$_] -ne $true }) + $results.AllChecksPassed = $allPassed + + $json = $results | ConvertTo-Json -Compress + Write-Output "VERIFY_RESULT_JSON=$json" + + if (-not $allPassed) { + throw "Environment verification failed: $json" + } +} -if (-not $allPassed) { - throw "Environment verification failed: $json" +if (-not $SkipExecution) { + Invoke-EnvironmentVerification }