From f374edc65d0cd1b1b9a1e7713169bdaf2314b13e Mon Sep 17 00:00:00 2001 From: Chris Grady <17553614+cgfixit@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:25:37 -0400 Subject: [PATCH 1/2] fix(vbr): validate installed release before proxy maintenance --- .github/workflows/pester.yml | 20 +- README.md | 252 ++++++------- SECURITY.md | 4 + sccmpatch.ps1 | 134 ++++++- tests/sccmpatch.Integration.Tests.ps1 | 19 +- tests/sccmpatch.Tests.ps1 | 499 ++++++++++++++++++-------- 6 files changed, 602 insertions(+), 326 deletions(-) diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index 9d47626..4717c8f 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -51,6 +51,7 @@ jobs: run: | $config = New-PesterConfiguration $config.Run.Path = './tests' + $config.Run.Exit = $true $config.Output.Verbosity = 'Detailed' $config.TestResult.Enabled = $true $config.TestResult.OutputPath = 'testResults-pwsh.xml' @@ -66,7 +67,11 @@ jobs: if-no-files-found: warn windows-powershell: - name: Pester (Windows PowerShell 5.1) + strategy: + fail-fast: false + matrix: + shell: [powershell, pwsh] + name: Pester (Windows ${{ matrix.shell }}) runs-on: windows-latest timeout-minutes: 10 steps: @@ -74,7 +79,7 @@ jobs: uses: actions/checkout@v4 - name: Validate PowerShell parser - shell: powershell + shell: ${{ matrix.shell }} run: | $errors = $null $null = [System.Management.Automation.Language.Parser]::ParseFile( @@ -86,7 +91,7 @@ jobs: } - name: Ensure Pester 5 is available - shell: powershell + shell: ${{ matrix.shell }} run: | $pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -ge 5 } | @@ -97,13 +102,14 @@ jobs: } - name: Run Pester tests - shell: powershell + shell: ${{ matrix.shell }} run: | $config = New-PesterConfiguration $config.Run.Path = './tests' + $config.Run.Exit = $true $config.Output.Verbosity = 'Detailed' $config.TestResult.Enabled = $true - $config.TestResult.OutputPath = 'testResults-windows-powershell.xml' + $config.TestResult.OutputPath = 'testResults-windows-${{ matrix.shell }}.xml' $config.TestResult.OutputFormat = 'NUnitXml' Invoke-Pester -Configuration $config @@ -111,6 +117,6 @@ jobs: if: always() uses: actions/upload-artifact@v4 with: - name: pester-results-windows-powershell - path: testResults-windows-powershell.xml + name: pester-results-windows-${{ matrix.shell }} + path: testResults-windows-${{ matrix.shell }}.xml if-no-files-found: warn diff --git a/README.md b/README.md index 3fafa28..c86c495 100644 --- a/README.md +++ b/README.md @@ -1,166 +1,116 @@ -# Veeam Proxy Maintenance for SCCM Patching -**Gracefully drain selected VMware proxies, stop Veeam services for patching/reboot, and return them to production without disrupting active backup jobs.** +# Veeam proxy maintenance for SCCM patching ---- +`sccmpatch.ps1` coordinates maintenance of selected Windows VMware backup proxies from a Windows Veeam Backup & Replication server or a Windows management host with the matching Veeam Console installed. -## Overview -This PowerShell script automates (minor config changes needed - see comments in ps1 script) safe maintenance windows for Veeam Backup & Replication VMware proxies in SCCM/ConfigMgr-driven patching workflows. It is intended to run on the **Veeam Backup & Replication server** or a **management/jump host** with the Veeam Console installed, and it remotely orchestrates VMware proxies via Veeam PowerShell and WinRM. +`Pre` disables the selected proxies, waits for their active backup tasks to drain, then stops their Veeam services over WinRM. `Post` starts those services, re-enables the proxies, and returns `3010` if the **script host** has a pending reboot. The SCCM task sequence patches the script host; service operations target the proxy names you supply. -The script implements a two-stage process—**Pre** (quiesce) and **Post** (recovery)—that ensures no backup tasks using the selected proxies are interrupted while their Veeam services are stopped and restarted during maintenance of the VBR server or management host. +## VBR detection and PowerShell requirements -## Why This Matters -Abruptly stopping Veeam services for Windows patching can: -* Interrupt active backup jobs mid-stream -* Corrupt backup repositories -* Trigger customer SLA violations -* Leave infrastructure in inconsistent states +The script identifies the local Veeam Server or Console release from Windows installed-product records. It reads `DisplayName` and `DisplayVersion` under the native 64-bit `HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall` key. Only the exact `Veeam Backup & Replication Server` and `Veeam Backup & Replication Console` product names qualify. The generic installer bundle is also used by Enterprise Manager and does not establish a backup-server installation. The presence of `powershell.exe` or `pwsh.exe` is not used to infer the VBR version. -This script prevents those issues by polling active backup sessions, gracefully disabling selected proxies in VBR, draining in-flight tasks that are using those proxies, and only then stopping Veeam services on the proxies via WinRM for safe maintenance of the central VBR/management server. +| Installed Veeam release | Required host | +| --- | --- | +| VBR 12, starting at 12.3.2 | 64-bit Windows PowerShell 5.1, Desktop edition | +| VBR 13.0 | 64-bit PowerShell 7, Core edition, meeting the installed module's declared minimum | +| VBR 13.1 | 64-bit PowerShell 7.6.3 or later, Core edition, meeting the installed module's declared minimum | -## Features -* **Parameterized proxy targeting**: Run against any proxy or batch of proxies without editing code -* **Intelligent task draining**: Polls active Veeam backup sessions and waits for tasks using the targeted proxies to complete (with configurable timeout) -* **Persistent logging**: All actions logged to a timestamped file for SCCM integration and audit trails -* **SCCM-aware exit codes**: Returns standard ConfigMgr codes (0, 3010) plus detailed diagnostics (10–99) for troubleshooting -* **Reboot signaling**: Detects Windows pending reboots **on the machine running the script** (VBR server or management host) and returns exit code 3010 so SCCM handles restart orchestration -* **Two-stage orchestration**: Designed for SCCM task sequences (Pre → Windows Updates → Post) that run on the VBR server or management/jump host, while coordinating remote proxies +The script also checks the selected module's `PowerShellVersion` and `CompatiblePSEditions` before importing it. The table describes the script's compatibility gates. Use the PowerShell build supported by Veeam for your installed VBR release; satisfying a manifest minimum does not certify every later PowerShell release. -## Requirements +The [VBR 12.3.2 archive](https://helpcenter.veeam.com/archive/backup/120/powershell/getting_started.html) requires Windows PowerShell 5.1. The [VBR 13 global changes](https://helpcenter.veeam.com/docs/vbr/powershell/global_changes_v13.html) require PowerShell 7. The [current Windows instructions](https://helpcenter.veeam.com/docs/vbr/powershell/running_ps_sessions_windows.html?ver=13), checked on October 3, 2026, apply to VBR 13.1.1.18 and specify PowerShell 7.6.3. Do not apply a historical 13.0 PowerShell build requirement to all of VBR 13. -### Veeam Environment -* **Veeam Backup & Replication 12.3.2+** (Server & Console) on the system where the script runs -* VMware proxies must be registered and functional in VBR -* Account running the script must have the **Backup Administrator** role (or equivalent) in VBR +After import, the script connects to `-VBRServer` under the current Windows identity and reads the actual backup server version with [`Get-VBRBackupServerInfo`](https://helpcenter.veeam.com/docs/vbr/powershell/get-vbrbackupserverinfo.html). The local installed release and connected server build are logged separately. Their major, minor, and build components must match. Windows Installer metadata may not reflect a hotfix revision, so the script leaves revision compatibility to Veeam's connection checks. The Console must match the server as described in [Veeam's Console requirements](https://helpcenter.veeam.com/docs/vbr/userguide/console_install_before_you_begin.html). -### Target Proxies -* **WinRM enabled** (for remote service management from the script host) -* **Local Administrator access** on the proxies for the account running the script -* Network connectivity from the script host to the VBR server (if separate) and to all target proxies +Missing, malformed, ambiguous, unsupported, or mismatched version evidence returns `99` before proxy lookup or service operations. An incompatible shell also returns `99` with the required executable and version. The script does not install PowerShell or relaunch itself. Configure SCCM with the correct executable below. Versions older than 12.3.2, VBR 13.2 or later, and future major versions require an explicit compatibility update. -### PowerShell & Account -* PowerShell 5.1+ on the VBR server or jump host where the script executes -* Execution Policy: `RemoteSigned` or `Bypass` for script execution -* MFA disabled for the service account (Veeam PowerShell sessions do not support MFA per KB4535) +A [Veeam installation report and product-team response](https://forums.veeam.com/viewtopic.php?f=2&start=30&t=100568) demonstrate why this distinction matters: the Server and Console MSI records remained at 12.3.2.3617 after a patch, while `Get-VBRBackupServerInfo` reported 12.3.2.4165. -## Installation -1. Download `sccmpatch.ps1` to your VBR server or management/jump host, or to an SCCM distribution point used to target that host. -2. Ensure the **Veeam PowerShell module** is available on the host running the script (installed with the Veeam Console). -3. Store the script in a shared location accessible to the SCCM client context on the VBR/management host (e.g., `C:\Scripts\` or a network UNC path). +The installed-product lookup uses [Windows Installer metadata](https://learn.microsoft.com/en-us/windows/win32/msi/uninstall-registry-key). It identifies the local release, including Console-only installations; it is not a remote server query or a guarantee of the exact patched binary version. + +## Prerequisites + +- Run in a fresh, 64-bit Windows PowerShell process with no existing Veeam server session. The script refuses to take over an existing session. +- Install the Veeam Console and its PowerShell module on the management host. Keep it compatible with the intended backup server. +- Supply `-VBRServer` on a Console-only host. A local Server installation can use the default `localhost` target. +- Use a Veeam Backup Administrator account with local administrator rights on the selected Windows proxies. Veeam PowerShell does not support MFA. See [KB4535](https://www.veeam.com/kb4535) and [SECURITY.md](SECURITY.md). +- Enable WinRM and allow connectivity from the script host to each target proxy. Configure Kerberos or HTTPS according to your environment. +- Use an execution policy that permits the script, such as `RemoteSigned` or `AllSigned`. + +A Windows Console can connect to a VBR 13 Linux backup server. The SCCM host and the proxies whose Windows services this script manages must still be Windows machines. This script does not patch a Linux appliance or manage Linux proxy services. ## Usage -### Basic Examples -**Pre-stage** for proxies "Proxy1" and "Proxy2": -```powershell -.\Sccmpatch.ps1 -Stage Pre -Proxies 'Proxy1','Proxy2' -Post-stage after patching: -powershell -.\Sccmpatch.ps1 -Stage Post -Proxies 'Proxy1','Proxy2' -Custom drain timeout (60 minutes): -powershell -.\Sccmpatch.ps1 -Stage Pre -Proxies 'Proxy1','Proxy2' -DrainTimeoutMinutes 60 -Parameters -Table -Parameter Type Default Description --Stage string Pre Execution stage: Pre (disable/drain/stop) or Post (start/re-enable/reboot signal). Invalid values return code 90. --Proxies string[] @('Proxy1','Proxy2') Array of proxy hostnames (as known to VBR) to target. Override with your environment names. --PollDelay int 30 Seconds between task-drain polls. Must be at least 1. Lower = faster detection, higher = less VBR API chatter. --DrainTimeoutMinutes int 30 Maximum wait time for active tasks using the targeted proxies to complete. Must be at least 1. If exceeded, exits with code 30. -Exit Codes -Table -Code Stage Meaning Action in SCCM -0 Both Success, no reboot Continue to next step -3010 Post Success, reboot pending on script host Schedule/execute reboot per ConfigMgr policy for the VBR/management host -10 Both Proxy objects not found Failure – verify proxy names in VBR -20 Pre Disable failed Failure – check VBR permissions/connectivity -30 Pre Task-drain timeout Failure – jobs still active after timeout; increase -DrainTimeoutMinutes -40 Pre Service stop failed Failure – check WinRM/remote access to proxy -50 Post Service start failed Failure – check proxy connectivity/service status -60 Post Re-enable failed Failure – critical; proxy may remain disabled -90 Both Invalid Stage argument Usage error – use -Stage Pre or -Stage Post -99 Both Unhandled error Failure – check log file for details -Integration with SCCM Task Sequences -Recommended Task Sequence Structure -This example assumes the task sequence targets the VBR server or management/jump host where the Veeam Console and Sccmpatch.ps1 are installed. The script will coordinate remote proxies during the maintenance of that host. -1. "Disable Veeam Proxies" (Run PowerShell Script step) -o Command: powershell.exe -ExecutionPolicy Bypass -File Sccmpatch.ps1 -Stage Pre -Proxies 'Proxy1','Proxy2' -o Success codes: 0 -o Continue on error: No (halt if Pre stage fails) -2. "Install Software Updates" (Install Software Updates step) -o Runs Windows Update, patches the VBR/management host, and may stage a reboot. -3. "Re-enable Veeam Proxies" (Run PowerShell Script step) -o Command: powershell.exe -ExecutionPolicy Bypass -File Sccmpatch.ps1 -Stage Post -Proxies 'Proxy1','Proxy2' -o Success codes: 0 3010 -o Continue on error: No -4. "Restart Computer" (Restart Computer step) -o Automatically triggered if any prior step returned 3010 (reboot pending on the VBR/management host). -SCCM Deployment Type (for Applications) -If deploying as a standalone application package to the VBR server or management host: -1. Create a new Application with deployment type "Script Installer". -2. Set installation script: point to Sccmpatch.ps1 with -Stage Pre and appropriate -Proxies. -3. On the Return Codes tab, add: -o 0 = Success -o 10, 20, 30, 40, 90, 99 = Failure -o 3010 = SoftReboot (if planning to use Post stage separately in another deployment or TS) -Logging -Log File Location -• %TEMP%\ProxyMaintenance_yyyyMMdd-HHmmss.log -• Example path (running as SYSTEM): C:\Users\SYSTEM\AppData\Local\Temp\ProxyMaintenance_20250924-143022.log -Log Format -text -2025-09-24 14:30:22 [INFO] Process begins (Stage: Pre, Proxies: Proxy1, Proxy2) -2025-09-24 14:30:23 [INFO] >>> Waiting for active tasks to drain… -2025-09-24 14:30:54 [WARN] Task still active on proxy Proxy1: Backup Job - VM-Server01 -2025-09-24 14:31:24 [INFO] >>> No active tasks – stopping services. -2025-09-24 14:31:25 [INFO] Stopping Veeam services on Proxy1 … -2025-09-24 14:31:28 [INFO] Stage Pre completed successfully -Note: Log files in %TEMP% may be cleaned by Windows; for production audits, consider redirecting logs to a persistent path (e.g., a network share or central logging solution) or integrating with Event Viewer. -Troubleshooting -Task Drain Timeout (Exit Code 30) -Symptom: Script times out waiting for active backup tasks using the targeted proxies to finish. -• Causes: Long-running backup jobs, Instant Recovery sessions, or job queue backlogs. -• Solutions: -o Increase -DrainTimeoutMinutes to 60+ for large job queues. -o Manually pause/suspend jobs feeding these proxies before running Pre stage: -Get-VBRJob | Where-Object { $_.TargetProxy -like 'Proxy1*' } | Suspend-VBRJob -o Check for Instant Recovery sessions in VBR Console that may be using proxy resources. -Service Stop Failure (Exit Code 40) -Symptom: Script fails to stop Veeam services on a proxy. -• Causes: WinRM not enabled, Firewall blocking WinRM, or insufficient permissions. -• Solutions: -o Verify WinRM is enabled: Invoke-Command -ComputerName 'Proxy1' -ScriptBlock { Get-Service WinRM | Select Status } -o Verify network connectivity and firewall rules allow WinRM (TCP 5985/5986). -o Confirm account has local admin rights on proxy. -Re-enable Failure (Exit Code 60) -Symptom: Proxies remain disabled in VBR after Post stage. -• Causes: VBR server connectivity lost or insufficient VBR permissions. -• Solutions: -o Test VBR connectivity: Get-VBRServer | Select Name, State -o Manually re-enable proxy in VBR Console: Home → Infrastructure → Backup Infrastructure → Proxies. -Performance & Sizing -• Drain poll interval: Default 30 seconds (adjustable via -PollDelay). -o For environments with <10 concurrent jobs: 30s is sufficient. -o For large job queues: consider -PollDelay 60 to reduce VBR API load. -• Drain timeout: Default 30 minutes (adjustable via -DrainTimeoutMinutes). -o Small deployments (1–5 proxies, <50 daily jobs): 30 min is typically adequate. -o Large deployments (>10 proxies, >100 daily jobs): increase to 60+ min. -Contributing -Found a bug or have a feature request? Please open an issue or submit a pull request. Suggested improvements: -• Support for Hyper-V proxies -• Event Viewer logging integration -• Automatic job suspension logic -• Multi-proxy parallel draining -Disclaimer -This script interacts with critical backup infrastructure. Test thoroughly in a non-production environment first. Ensure you have backups of your Veeam configuration and a rollback plan before running in production. The author assumes no liability for data loss, service disruption, or other damages resulting from script execution. -Quick Start Checklist -• Download script to VBR server or management/jump host (or SCCM distribution point targeting that host) -• Verify Veeam PowerShell module is installed (via Veeam Console) on that host -• Confirm account running the script has Backup Administrator role in VBR -• Confirm account has local admin on all target proxies -• Enable WinRM on all target proxies -• Test Pre stage on one proxy: .\Sccmpatch.ps1 -Stage Pre -Proxies 'TestProxy' -• Review log file output -• Test Post stage: .\Sccmpatch.ps1 -Stage Post -Proxies 'TestProxy' -• Integrate into SCCM task sequence targeting the VBR/management host with proper success codes -• Deploy to production with change management approval +Copy `sccmpatch.ps1` to the VBR server or management host, such as `C:\Scripts`. Run it in the required shell, with your actual proxy names and backup server target. + +```powershell +.\sccmpatch.ps1 -Stage Pre -Proxies 'Proxy1','Proxy2' -VBRServer 'VBRServer' +.\sccmpatch.ps1 -Stage Post -Proxies 'Proxy1','Proxy2' -VBRServer 'VBRServer' +.\sccmpatch.ps1 -Stage Pre -Proxies 'Proxy1','Proxy2' -DrainTimeoutMinutes 60 -VBRServer 'VBRServer' +``` + +`-WhatIf` returns before installation discovery, Veeam module import, connection, or remote operations. It previews the requested maintenance action; it does **not** validate prerequisites or query current backup activity. + +```powershell +.\sccmpatch.ps1 -Stage Pre -Proxies 'Proxy1','Proxy2' -WhatIf +``` + +| Parameter | Default | Purpose | +| --- | --- | --- | +| `-Stage` | `Pre` | `Pre` or `Post`. Other values return `90`. | +| `-Proxies` | The two example names in the script | Windows proxy names as registered in VBR. Always override these for your environment. | +| `-VBRServer` | Local server when installed | Explicit backup server name. Required on a Console-only host. | +| `-PollDelay` | `30` | Seconds between drain polls, from 1 through 3600. | +| `-DrainTimeoutMinutes` | `30` | Maximum drain duration, from 1 through 1440 minutes. | + +## SCCM task sequence + +Use a **Run Command Line** step with the matching shell. These examples construct the proxy array inside PowerShell because native `-File` argument passing does not reliably preserve a multi-element array in Windows PowerShell 5.1. Keep `exit $LASTEXITCODE` to propagate the script's SCCM code from `-Command`. + +VBR 12.3.2, from a 64-bit SCCM step: + +```text +powershell.exe -NoLogo -NoProfile -NonInteractive -Command "& 'C:\Scripts\sccmpatch.ps1' -Stage Pre -Proxies @('Proxy1','Proxy2') -VBRServer 'VBRServer'; exit $LASTEXITCODE" +``` + +VBR 13, using a supported PowerShell 7 installation: + +```text +"C:\Program Files\PowerShell\7\pwsh.exe" -NoLogo -NoProfile -NonInteractive -Command "& 'C:\Scripts\sccmpatch.ps1' -Stage Pre -Proxies @('Proxy1','Proxy2') -VBRServer 'VBRServer'; exit $LASTEXITCODE" +``` + +Use this sequence: + +1. Run `Pre`. Accept only `0`, and stop the sequence on failure. +2. Install the approved Windows updates on the VBR or management host. +3. Run the same version-appropriate command with `-Stage Post`. Accept `0` and `3010`. +4. Let ConfigMgr's restart policy handle `3010` for the script host. + +Disable the task-sequence option to run as a 32-bit process. The startup check rejects 32-bit hosts. For an Application deployment type, configure the return-code mappings below and provide a separate Post recovery step. + +| Code | Meaning | SCCM action | +| --- | --- | --- | +| `0` | Success | Continue | +| `3010` | Post succeeded; script host has a pending reboot | Soft reboot | +| `10` | Empty proxy list or no matching proxy objects | Fail and verify names | +| `20` | Pre failed to disable proxies | Fail and inspect VBR permissions and connectivity | +| `30` | Pre task-drain timeout | Fail; inspect active work and timeout | +| `40` | Pre failed to stop proxy services | Fail and inspect WinRM or service access | +| `50` | Post failed to start proxy services | Fail and inspect proxy service health | +| `60` | Post failed to re-enable proxies | Fail; restore proxy availability manually if needed | +| `90` | Invalid Stage | Fail and correct the command | +| `99` | Startup/version/module/connection failure or unhandled error | Fail and inspect the log | + +A Pre failure after proxy disable can leave proxies disabled. Inspect the log and recover through Post or the Veeam Console when the cause is resolved. + +## Logs and troubleshooting + +Logs are written to `%TEMP%\ProxyMaintenance_yyyyMMdd-HHmmss.log` in the account running the script. They record the local Veeam installation, PowerShell host, connected server build, stage, and operation failures. Retain these files with your SCCM deployment evidence; temporary files may be cleaned by Windows. + +For `99`, check the logged installed version and required PowerShell executable first. Confirm the Console and server releases match, that `-VBRServer` names the intended server, and that a single Veeam module installation is discoverable. Start a fresh shell if a Veeam session already exists. Repair missing or conflicting product records instead of guessing a version. Complete any Veeam certificate trust setup through the supported administrative process; the script does not force certificate acceptance. + +For `30`, inspect the selected proxies' active work in Veeam before increasing the timeout. The default poll interval is 30 seconds and the default timeout is 30 minutes. For `40` or `50`, inspect WinRM and the `Veeam*` services on the named proxies. For `60`, inspect server connectivity and re-enable the proxies in the Console after services are healthy. + +## Validation and limitations + +The tests execute the script with isolated Windows installation, PowerShell host, Veeam, and WinRM doubles. They cover version decisions, refusal before mutation, ordering, and SCCM return values. CI runs on PowerShell 7 and Windows PowerShell 5.1. These checks do not load real Veeam assemblies or perform live proxy maintenance. +The existing drain algorithm uses `task.Info.WorkDetails.SourceProxyId`. Veeam's public PowerShell reference does not document that internal property. The exact VBR 13 Windows installer records have not been verified against a live installation. Validate task-to-proxy matching on your VBR build before production use, along with registry metadata, module loading, service behavior, and Windows process exit `3010`. Test a complete Pre/Post cycle in a non-production environment and retain a recovery plan. diff --git a/SECURITY.md b/SECURITY.md index 448d93c..8cbb704 100644 --- a/SECURITY.md +++ b/SECURITY.md @@ -25,3 +25,7 @@ This script operates with elevated privileges and interacts with infrastructure - **WinRM**: The script uses `Invoke-Command` over WinRM. Ensure WinRM is configured with HTTPS (port 5986) or Kerberos authentication rather than basic/plaintext HTTP. - **Service account**: The Veeam Backup Administrator account used to run this script should follow least-privilege principles and must not have MFA enabled (Veeam PowerShell limitation per KB4535). - **Execution policy**: The script requires an execution policy that permits script execution. Use `RemoteSigned` or `AllSigned` rather than `Unrestricted`. + +- **Startup checks**: The script uses native Windows installed-product metadata to validate the required shell, then confirms the connected server build before proxy operations. It refuses unsupported or inconsistent releases with exit `99`. Run it in a fresh 64-bit process and pass an explicit `-VBRServer` on Console-only hosts. +- **Veeam connections**: Connections use the current Windows identity. The script does not force TLS certificate acceptance or disconnect a pre-existing caller session. It closes only a session it opened. +- **Validation boundary**: Mocked local checks verify control flow and return values. They do not establish live Veeam assembly, Windows installer metadata, task-to-proxy mapping, WinRM, or service compatibility. Validate those on a non-production deployment before rollout. diff --git a/sccmpatch.ps1 b/sccmpatch.ps1 index 76655a4..d5d172a 100644 --- a/sccmpatch.ps1 +++ b/sccmpatch.ps1 @@ -21,14 +21,15 @@ Exit codes: 3010 Reboot pending (Post, for SCCM) #> -[CmdletBinding()] +[CmdletBinding(SupportsShouldProcess)] param( [string]$Stage = 'Pre', [string[]]$Proxies = @('F-1', 'M-1'), [ValidateRange(1, 3600)] [int]$PollDelay = 30, [ValidateRange(1, 1440)] - [int]$DrainTimeoutMinutes = 30 + [int]$DrainTimeoutMinutes = 30, + [string]$VBRServer ) $script:LogPath = $null @@ -60,6 +61,89 @@ function Write-ProxyLog { } } +function Get-ProxyHostEnvironment { + [pscustomobject]@{ + IsWindows = [Environment]::OSVersion.Platform -eq [PlatformID]::Win32NT + Is64BitProcess = [Environment]::Is64BitProcess + Edition = $PSVersionTable.PSEdition + Version = $PSVersionTable.PSVersion + } +} + +function Get-ProxyVBRConfiguration { + param([string]$VBRServer) + + $hostEnvironment = Get-ProxyHostEnvironment + if (-not $hostEnvironment.IsWindows -or -not $hostEnvironment.Is64BitProcess) { + throw 'Run this script in a 64-bit Windows PowerShell process on a Veeam Server or Console host.' + } + + $products = @( + Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\*' -ErrorAction Stop | + Where-Object { $_.DisplayName -in @('Veeam Backup & Replication Server', 'Veeam Backup & Replication Console') } + ) + if ($products.Count -eq 0) { + throw 'No Veeam Server or Console installation metadata was found in the native HKLM Uninstall registry.' + } + $versions = @($products | ForEach-Object { + $parsed = $null + if (-not [version]::TryParse([string]$_.DisplayVersion, [ref]$parsed) -or $parsed.Build -lt 0) { + throw 'Veeam installation DisplayVersion is missing or malformed. Repair the Server/Console installation.' + } + $parsed + }) + $release = $versions[0].ToString(3) + if (@($versions | Where-Object { $_.ToString(3) -ne $release }).Count -gt 0) { + throw 'Veeam Server and Console installation releases disagree. Repair the installation before maintenance.' + } + $version = $versions[0] + $edition = 'Core' + $minimum = [version]'7.0' + if ($version.Major -eq 12 -and $version -ge [version]'12.3.2') { + $edition = 'Desktop' + $minimum = [version]'5.1' + } + elseif ($version.Major -eq 13 -and $version.Minor -eq 1) { + $minimum = [version]'7.6.3' + } + elseif ($version.Major -ne 13 -or $version.Minor -ne 0) { + throw ('Unsupported installed Veeam release {0}. Supported releases are v12.3.2+, within v12, and v13.0/v13.1.' -f $version) + } + if ($hostEnvironment.Edition -ne $edition -or $hostEnvironment.Version -lt $minimum -or + ($edition -eq 'Desktop' -and $hostEnvironment.Version -ge [version]'6.0') -or + ($edition -eq 'Core' -and $hostEnvironment.Version.Major -ne 7)) { + throw ('Installed Veeam {0} requires {1} PowerShell {2} (v12 uses powershell.exe 5.1; v13 uses pwsh.exe 7.x). Running {3} {4}. Rerun the same arguments in the required 64-bit shell.' -f + $version, $edition, $minimum, $hostEnvironment.Edition, $hostEnvironment.Version) + } + if ([string]::IsNullOrWhiteSpace($VBRServer)) { + if ($products.DisplayName -contains 'Veeam Backup & Replication Server') { + $VBRServer = 'localhost' + } + else { + throw 'A Console-only host requires an explicit -VBRServer target.' + } + } + + $modules = @(Get-Module -ListAvailable -Name Veeam.Backup.PowerShell -ErrorAction Stop | + Sort-Object -Property Path -Unique) + if ($modules.Count -ne 1 -or -not [IO.Path]::IsPathRooted($modules[0].Path)) { + throw 'Expected one unambiguous installed Veeam.Backup.PowerShell module path. Repair module discovery before maintenance.' + } + $module = $modules[0] + if (($module.PowerShellVersion -and $hostEnvironment.Version -lt $module.PowerShellVersion) -or + ($module.CompatiblePSEditions.Count -gt 0 -and $hostEnvironment.Edition -notin $module.CompatiblePSEditions)) { + throw ('The installed Veeam module manifest is incompatible with {0} {1}. Required minimum PowerShell: {2}; editions: {3}.' -f + $hostEnvironment.Edition, $hostEnvironment.Version, $module.PowerShellVersion, ($module.CompatiblePSEditions -join ', ')) + } + $loaded = @(Get-Module -Name Veeam.Backup.PowerShell -ErrorAction Stop) + if (@($loaded | Where-Object { $_.Path -ne $module.Path }).Count -gt 0) { + throw 'A different Veeam module is already loaded. Start a clean PowerShell session and rerun.' + } + Write-ProxyLog ('Installed Veeam metadata: {0}; runtime: {1} {2}; target: {3}; module: {4}' -f + ($versions -join ', '), $hostEnvironment.Edition, $hostEnvironment.Version, $VBRServer, $module.Path) -ToConsole + [pscustomobject]@{ Version = $version; Server = $VBRServer; ModulePath = $module.Path } +} + function Wait-ProxyTasksToDrain { [CmdletBinding()] param( @@ -176,11 +260,13 @@ function Invoke-ProxyMaintenance { [Parameter(Mandatory)] [int]$PollDelay, [Parameter(Mandatory)] - [int]$DrainTimeoutMinutes + [int]$DrainTimeoutMinutes, + [string]$VBRServer ) $ErrorActionPreference = 'Stop' Initialize-ProxyLog + $ownsConnection = $false try { Write-ProxyLog ('Process begins (Stage: {0}, Proxies: {1})' -f $Stage, ($Proxies -join ', ')) -ToConsole @@ -199,7 +285,26 @@ function Invoke-ProxyMaintenance { return 0 } - Import-Module Veeam.Backup.PowerShell -ErrorAction Stop + $configuration = Get-ProxyVBRConfiguration -VBRServer $VBRServer + Import-Module -Name $configuration.ModulePath -ErrorAction Stop + if (@(Get-VBRServerSession -ErrorAction Stop).Count -gt 0) { + throw 'An existing Veeam connection is present. Start a clean PowerShell session and rerun; the existing session will not be disconnected.' + } + Connect-VBRServer -Server $configuration.Server -ErrorAction Stop | Out-Null + $ownsConnection = $true + $serverInfo = @(Get-VBRBackupServerInfo -ErrorAction Stop) + if ($serverInfo.Count -ne 1) { + throw 'Expected exactly one connected VBR server information object.' + } + $serverInfo = $serverInfo[0] + $serverVersion = $null + if (-not [version]::TryParse([string]$serverInfo.Build, [ref]$serverVersion) -or $serverVersion.Build -lt 0) { + throw 'The connected VBR server did not report a valid Build.' + } + Write-ProxyLog ('Connected VBR server: {0}; build: {1}; installed release metadata: {2}' -f $serverInfo.Name, $serverVersion, $configuration.Version) -ToConsole + if ($serverVersion.ToString(3) -ne $configuration.Version.ToString(3)) { + throw 'Connected VBR release differs from the installed Server/Console release. Use a matching Console installation.' + } $proxyObjects = @(Get-VBRViProxy -Name $Proxies -ErrorAction Stop) if ($proxyObjects.Count -eq 0) { Write-ProxyLog 'No matching proxies found.' 'ERROR' -ToConsole @@ -267,8 +372,27 @@ function Invoke-ProxyMaintenance { Write-ProxyLog ('Unhandled error: {0}' -f $_) 'ERROR' -ToConsole return 99 } + finally { + if ($ownsConnection) { + try { + Disconnect-VBRServer -ErrorAction Stop | Out-Null + } + catch { + Write-ProxyLog ('Unable to disconnect owned Veeam session: {0}' -f $_) 'WARN' -ToConsole + } + } + } } if ($MyInvocation.InvocationName -ne '.') { - exit (Invoke-ProxyMaintenance -Stage $Stage -Proxies $Proxies -PollDelay $PollDelay -DrainTimeoutMinutes $DrainTimeoutMinutes) + $maintenanceParameters = @{ + Stage = $Stage; Proxies = $Proxies; VBRServer = $VBRServer + PollDelay = $PollDelay; DrainTimeoutMinutes = $DrainTimeoutMinutes + } + foreach ($commonParameter in @('WhatIf', 'Confirm')) { + if ($PSBoundParameters.ContainsKey($commonParameter)) { + $maintenanceParameters[$commonParameter] = $PSBoundParameters[$commonParameter] + } + } + exit (Invoke-ProxyMaintenance @maintenanceParameters) } diff --git a/tests/sccmpatch.Integration.Tests.ps1 b/tests/sccmpatch.Integration.Tests.ps1 index 67f6255..bbbaf68 100644 --- a/tests/sccmpatch.Integration.Tests.ps1 +++ b/tests/sccmpatch.Integration.Tests.ps1 @@ -1,10 +1,3 @@ -BeforeAll { - $PowerShellCommand = Get-Command pwsh -ErrorAction SilentlyContinue - if (-not $PowerShellCommand) { - $PowerShellCommand = Get-Command powershell.exe -ErrorAction SilentlyContinue - } -} - Describe 'sccmpatch.ps1 process entrypoint' { It 'returns SCCM code 90 for an invalid stage without loading Veeam' { @@ -23,10 +16,20 @@ Describe 'sccmpatch.ps1 process entrypoint' { 'Proxy1' ) - $output = & $PowerShellCommand.Source @arguments 2>&1 | Out-String + $output = & ((Get-Process -Id $PID).Path) @arguments 2>&1 | Out-String $exitCode = $LASTEXITCODE $exitCode | Should -Be 90 $output | Should -Match 'Invalid -Stage argument' } } + +Describe 'sccmpatch.ps1 CLI WhatIf' { + It 'accepts and forwards WhatIf before installation discovery' { + $scriptPath = (Resolve-Path (Join-Path $PSScriptRoot '..\sccmpatch.ps1')).Path + $output = & ((Get-Process -Id $PID).Path) -NoLogo -NoProfile -NonInteractive -File $scriptPath -Stage Pre -Proxies Proxy1 -VBRServer fixture-vbr -WhatIf 2>&1 | Out-String + $LASTEXITCODE | Should -Be 0 + $output | Should -Match 'What if:' + $output | Should -Not -Match 'Unhandled error' + } +} diff --git a/tests/sccmpatch.Tests.ps1 b/tests/sccmpatch.Tests.ps1 index b456285..be8ce00 100644 --- a/tests/sccmpatch.Tests.ps1 +++ b/tests/sccmpatch.Tests.ps1 @@ -5,11 +5,15 @@ BeforeAll { . $ScriptPath # Pester requires a command to exist before it can mock it. - function Get-VBRViProxy {} + function Get-VBRViProxy { param($Name) } function Disable-VBRViProxy {} function Get-VBRBackupSession {} function Get-VBRTaskSession {} function Enable-VBRViProxy {} + function Get-VBRServerSession {} + function Connect-VBRServer { param($Server) } + function Disconnect-VBRServer {} + function Get-VBRBackupServerInfo {} function Get-ScriptAst { $errors = $null @@ -21,212 +25,397 @@ BeforeAll { } } -Describe 'sccmpatch.ps1 parser and parameters' { - - It 'parses without syntax errors' { - { Get-ScriptAst } | Should -Not -Throw +Describe 'sccmpatch.ps1 behavior' { + BeforeEach { + Mock Get-ProxyHostEnvironment { + [pscustomobject]@{ IsWindows = $true; Is64BitProcess = $true; Edition = 'Core'; Version = [version]'7.6.3' } + } + Mock Get-ItemProperty { + [pscustomobject]@{ DisplayName = 'Veeam Backup & Replication Server'; DisplayVersion = '13.1.1.18' } + } + Mock Get-Module { + if ($ListAvailable) { + [pscustomobject]@{ Path = Join-Path $TestDrive 'Veeam.Backup.PowerShell.psd1'; PowerShellVersion = [version]'7.6.3'; CompatiblePSEditions = @('Core') } + } + } + Mock Get-VBRServerSession {} + Mock Connect-VBRServer {} + Mock Disconnect-VBRServer {} + Mock Get-VBRBackupServerInfo { [pscustomobject]@{ Build = [version]'13.1.1.18' } } } - It 'declares the expected script parameters' { - $ast = Get-ScriptAst - $names = $ast.ParamBlock.Parameters.Name.VariablePath.UserPath + Describe 'sccmpatch.ps1 parser and parameters' { - $names | Should -Contain 'Stage' - $names | Should -Contain 'Proxies' - $names | Should -Contain 'PollDelay' - $names | Should -Contain 'DrainTimeoutMinutes' - } + It 'parses without syntax errors' { + { Get-ScriptAst } | Should -Not -Throw + } + + It 'declares the expected script parameters' { + $ast = Get-ScriptAst + $names = $ast.ParamBlock.Parameters.Name.VariablePath.UserPath + + $names | Should -Contain 'Stage' + $names | Should -Contain 'VBRServer' + ($names -join ',') | Should -Be 'Stage,Proxies,PollDelay,DrainTimeoutMinutes,VBRServer' + $names | Should -Contain 'Proxies' + $names | Should -Contain 'PollDelay' + $names | Should -Contain 'DrainTimeoutMinutes' + } - It 'keeps numeric polling parameters above zero' { - $ast = Get-ScriptAst - foreach ($name in @('PollDelay', 'DrainTimeoutMinutes')) { - $param = $ast.ParamBlock.Parameters | Where-Object { - $_.Name.VariablePath.UserPath -eq $name + It 'keeps numeric polling parameters above zero' { + $ast = Get-ScriptAst + foreach ($name in @('PollDelay', 'DrainTimeoutMinutes')) { + $param = $ast.ParamBlock.Parameters | Where-Object { + $_.Name.VariablePath.UserPath -eq $name + } + $range = $param.Attributes | Where-Object { $_.TypeName.Name -eq 'ValidateRange' } + $range | Should -Not -BeNullOrEmpty + $range.PositionalArguments[0].Value | Should -Be 1 } - $range = $param.Attributes | Where-Object { $_.TypeName.Name -eq 'ValidateRange' } - $range | Should -Not -BeNullOrEmpty - $range.PositionalArguments[0].Value | Should -Be 1 } - } - It 'uses ASCII source so Windows PowerShell 5.1 reads the script consistently' { - $bytes = [System.IO.File]::ReadAllBytes($ScriptPath) - @($bytes | Where-Object { $_ -gt 127 }) | Should -BeNullOrEmpty + It 'uses ASCII source so Windows PowerShell 5.1 reads the script consistently' { + $bytes = [System.IO.File]::ReadAllBytes($ScriptPath) + @($bytes | Where-Object { $_ -gt 127 }) | Should -BeNullOrEmpty + } } -} -Describe 'sccmpatch.ps1 SCCM exit-code contract' { + Describe 'sccmpatch.ps1 SCCM exit-code contract' { - It 'returns 90 for an invalid stage before loading Veeam' { - Mock Write-ProxyLog {} - Mock Import-Module {} + It 'returns 90 for an invalid stage before loading Veeam' { + Mock Write-ProxyLog {} + Mock Import-Module {} - $result = Invoke-ProxyMaintenance -Stage 'Invalid' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 + $result = Invoke-ProxyMaintenance -Stage 'Invalid' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 - $result | Should -Be 90 - Should -Invoke Import-Module -Times 0 - } + $result | Should -Be 90 + Should -Invoke Import-Module -Times 0 + } - It 'returns 10 when no proxy names are supplied' { - Mock Write-ProxyLog {} - Mock Import-Module {} + It 'returns 10 when no proxy names are supplied' { + Mock Write-ProxyLog {} + Mock Import-Module {} - $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @() -PollDelay 1 -DrainTimeoutMinutes 1 + $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @() -PollDelay 1 -DrainTimeoutMinutes 1 - $result | Should -Be 10 - Should -Invoke Import-Module -Times 0 - } + $result | Should -Be 10 + Should -Invoke Import-Module -Times 0 + } - It 'returns 20 when disabling a proxy fails' { - Mock Write-ProxyLog {} - Mock Import-Module {} - Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } - Mock Disable-VBRViProxy { throw 'disable failed' } + It 'returns 20 when disabling a proxy fails' { + Mock Write-ProxyLog {} + Mock Import-Module {} + Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } + Mock Disable-VBRViProxy { throw 'disable failed' } - $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 + $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 - $result | Should -Be 20 - } + $result | Should -Be 20 + } - It 'returns 0 for a successful Pre stage with no active sessions' { - Mock Write-ProxyLog {} - Mock Import-Module {} - Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } - Mock Disable-VBRViProxy {} - Mock Get-VBRBackupSession { @() } - Mock Invoke-Command {} + It 'returns 0 for a successful Pre stage with no active sessions' { + Mock Write-ProxyLog {} + Mock Import-Module {} + Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } + Mock Disable-VBRViProxy {} + Mock Get-VBRBackupSession { @() } + Mock Invoke-Command {} - $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 + $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 - $result | Should -Be 0 - Should -Invoke Disable-VBRViProxy -Times 1 - Should -Invoke Invoke-Command -Times 1 - } + $result | Should -Be 0 + Should -Invoke Disable-VBRViProxy -Times 1 + Should -Invoke Invoke-Command -Times 1 + } - It 'returns 40 when stopping proxy services fails' { - Mock Write-ProxyLog {} - Mock Import-Module {} - Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } - Mock Disable-VBRViProxy {} - Mock Get-VBRBackupSession { @() } - Mock Invoke-Command { throw 'stop failed' } + It 'returns 40 when stopping proxy services fails' { + Mock Write-ProxyLog {} + Mock Import-Module {} + Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } + Mock Disable-VBRViProxy {} + Mock Get-VBRBackupSession { @() } + Mock Invoke-Command { throw 'stop failed' } - $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 + $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 - $result | Should -Be 40 - } + $result | Should -Be 40 + } - It 'returns 50 when starting proxy services fails' { - Mock Write-ProxyLog {} - Mock Import-Module {} - Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } - Mock Invoke-Command { throw 'start failed' } + It 'returns 50 when starting proxy services fails' { + Mock Write-ProxyLog {} + Mock Import-Module {} + Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } + Mock Invoke-Command { throw 'start failed' } - $result = Invoke-ProxyMaintenance -Stage 'Post' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 + $result = Invoke-ProxyMaintenance -Stage 'Post' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 - $result | Should -Be 50 - } + $result | Should -Be 50 + } - It 'returns 60 when re-enabling proxies fails' { - Mock Write-ProxyLog {} - Mock Import-Module {} - Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } - Mock Invoke-Command {} - Mock Enable-VBRViProxy { throw 'enable failed' } + It 'returns 60 when re-enabling proxies fails' { + Mock Write-ProxyLog {} + Mock Import-Module {} + Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } + Mock Invoke-Command {} + Mock Enable-VBRViProxy { throw 'enable failed' } - $result = Invoke-ProxyMaintenance -Stage 'Post' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 + $result = Invoke-ProxyMaintenance -Stage 'Post' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 - $result | Should -Be 60 - } + $result | Should -Be 60 + } + + It 'returns 99 when the Veeam module cannot load' { + Mock Write-ProxyLog {} + Mock Import-Module { throw 'module missing' } + + $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 + + $result | Should -Be 99 + } + + It 'supports WhatIf without loading Veeam' { + Mock Write-ProxyLog {} + Mock Import-Module {} + + $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 -WhatIf + + $result | Should -Be 0 + Should -Invoke Import-Module -Times 0 + } + + It 'returns 30 when a targeted task remains past the drain timeout' { + Mock Write-ProxyLog {} + $script:DrainTestTime = [datetime]'2026-01-01T00:00:00' + $script:DrainSleepSeconds = $null + Mock Get-Date { $script:DrainTestTime } + Mock Start-Sleep { + param($Seconds) + $script:DrainSleepSeconds = $Seconds + $script:DrainTestTime = $script:DrainTestTime.AddMinutes(1) + } + Mock Get-VBRBackupSession { @([pscustomobject]@{ State = 'Working' }) } + Mock Get-VBRTaskSession { + @([pscustomobject]@{ + Status = 'InProgress' + Name = 'Backup task' + Info = [pscustomobject]@{ + WorkDetails = [pscustomobject]@{ SourceProxyId = 'proxy-1' } + } + }) + } + Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } - It 'returns 99 when the Veeam module cannot load' { - Mock Write-ProxyLog {} - Mock Import-Module { throw 'module missing' } + $result = $null + try { + Wait-ProxyTasksToDrain -ProxyObjects @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) -PollDelay 120 -DrainTimeoutMinutes 1 + } + catch [System.TimeoutException] { + $result = 30 + } + + $result | Should -Be 30 + $script:DrainSleepSeconds | Should -Be 60 + Should -Invoke Start-Sleep -Times 1 + } - $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 + It 'ignores active tasks that use an unselected proxy' { + Mock Write-ProxyLog {} + Mock Start-Sleep {} + Mock Get-VBRBackupSession { @([pscustomobject]@{ State = 'Working' }) } + Mock Get-VBRTaskSession { + @([pscustomobject]@{ + Status = 'InProgress' + Name = 'Other proxy task' + Info = [pscustomobject]@{ + WorkDetails = [pscustomobject]@{ SourceProxyId = 'other-proxy' } + } + }) + } - $result | Should -Be 99 + { Wait-ProxyTasksToDrain -ProxyObjects @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) -PollDelay 1 -DrainTimeoutMinutes 1 } | + Should -Not -Throw + Should -Invoke Start-Sleep -Times 0 + } } - It 'supports WhatIf without loading Veeam' { - Mock Write-ProxyLog {} - Mock Import-Module {} + Describe 'sccmpatch.ps1 Post stage' { + + It 'starts services, re-enables proxies, and returns 3010 for a pending reboot' { + Mock Write-ProxyLog {} + Mock Import-Module {} + Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } + Mock Invoke-Command {} + Mock Enable-VBRViProxy {} + Mock Test-Path { $true } - $result = Invoke-ProxyMaintenance -Stage 'Pre' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 -WhatIf + $result = Invoke-ProxyMaintenance -Stage 'Post' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 - $result | Should -Be 0 - Should -Invoke Import-Module -Times 0 + $result | Should -Be 3010 + Should -Invoke Invoke-Command -Times 1 + Should -Invoke Enable-VBRViProxy -Times 1 + } } - It 'returns 30 when a targeted task remains past the drain timeout' { - Mock Write-ProxyLog {} - $script:DrainTestTime = [datetime]'2026-01-01T00:00:00' - $script:DrainSleepSeconds = $null - Mock Get-Date { $script:DrainTestTime } - Mock Start-Sleep { - param($Seconds) - $script:DrainSleepSeconds = $Seconds - $script:DrainTestTime = $script:DrainTestTime.AddMinutes(1) - } - Mock Get-VBRBackupSession { @([pscustomobject]@{ State = 'Working' }) } - Mock Get-VBRTaskSession { - @([pscustomobject]@{ - Status = 'InProgress' - Name = 'Backup task' - Info = [pscustomobject]@{ - WorkDetails = [pscustomobject]@{ SourceProxyId = 'proxy-1' } + Describe 'sccmpatch.ps1 VBR startup gate' { + BeforeEach { + Mock Write-ProxyLog {} + Mock Import-Module {} + Mock Get-VBRViProxy { [pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' } } + Mock Disable-VBRViProxy {} + Mock Get-VBRBackupSession {} + Mock Invoke-Command {} + } + + It 'retains the original positional maintenance arguments' { + Invoke-ProxyMaintenance Pre @('Proxy1') 1 1 | Should -Be 0 + Should -Invoke Get-VBRViProxy -Times 1 -ParameterFilter { $Name -contains 'Proxy1' } + } + + It 'returns 30 from maintenance when draining times out and disconnects its own session' { + Mock Wait-ProxyTasksToDrain { throw [System.TimeoutException]::new('fixture timeout') } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 30 + Should -Invoke Disconnect-VBRServer -Times 1 + Should -Invoke Invoke-Command -Times 0 + } + + It 'rejects missing or multiple server information records before maintenance' -ForEach @( + @{ Records = 0 }; @{ Records = 2 } + ) { + Mock Get-VBRBackupServerInfo { + for ($i = 0; $i -lt $Records; $i++) { [pscustomobject]@{ Build = [version]'13.1.1.18' } } + } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Get-VBRViProxy -Times 0 + Should -Invoke Disconnect-VBRServer -Times 1 + } + + It 'accepts supported installed release on ' -ForEach @( + @{ Product = '12.3.2.4854'; Edition = 'Desktop'; Runtime = '5.1'; Minimum = '5.1' } + @{ Product = '13.0.0.4967'; Edition = 'Core'; Runtime = '7.4.7'; Minimum = '7.4.7' } + @{ Product = '13.1.1.18'; Edition = 'Core'; Runtime = '7.6.3'; Minimum = '7.6.3' } + ) { + Mock Get-ProxyHostEnvironment { + [pscustomobject]@{ IsWindows = $true; Is64BitProcess = $true; Edition = $Edition; Version = [version]$Runtime } + } + Mock Get-ItemProperty { [pscustomobject]@{ DisplayName = 'Veeam Backup & Replication Server'; DisplayVersion = $Product } } + Mock Get-Module { + if ($ListAvailable) { + [pscustomobject]@{ Path = Join-Path $TestDrive 'Veeam.Backup.PowerShell.psd1'; PowerShellVersion = [version]$Minimum; CompatiblePSEditions = @($Edition) } } - }) + } + Mock Get-VBRBackupServerInfo { [pscustomobject]@{ Build = [version]$Product } } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 0 + Should -Invoke Connect-VBRServer -Times 1 -ParameterFilter { $Server -eq 'localhost' } + Should -Invoke Disconnect-VBRServer -Times 1 + Should -Invoke Invoke-Command -Times 1 } - Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } - $result = $null - try { - Wait-ProxyTasksToDrain -ProxyObjects @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) -PollDelay 120 -DrainTimeoutMinutes 1 + It 'refuses installed metadata before import or connection' -ForEach @( + @{ Product = '' }; @{ Product = 'invalid' }; @{ Product = '12.2.0.1' }; @{ Product = '14.0.0.1' }; @{ Product = '13.2.0.1' } + @{ Product = '12.3.2.4854' } + ) { + Mock Get-ItemProperty { [pscustomobject]@{ DisplayName = 'Veeam Backup & Replication Server'; DisplayVersion = $Product } } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Import-Module -Times 0 + Should -Invoke Connect-VBRServer -Times 0 + Should -Invoke Get-VBRViProxy -Times 0 } - catch [System.TimeoutException] { - $result = 30 + + It 'refuses non-Windows and 32-bit environments before registry discovery' -ForEach @( + @{ Windows = $false; Bit64 = $true }; @{ Windows = $true; Bit64 = $false } + ) { + Mock Get-ProxyHostEnvironment { [pscustomobject]@{ IsWindows = $Windows; Is64BitProcess = $Bit64 } } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Get-ItemProperty -Times 0 + Should -Invoke Import-Module -Times 0 } - $result | Should -Be 30 - $script:DrainSleepSeconds | Should -Be 60 - Should -Invoke Start-Sleep -Times 1 - } + It 'refuses missing installation and mixed releases' -ForEach @(@{ Mixed = $false }; @{ Mixed = $true }) { + Mock Get-ItemProperty { + if ($Mixed) { + [pscustomobject]@{ DisplayName = 'Veeam Backup & Replication Server'; DisplayVersion = '12.3.2.4854' } + [pscustomobject]@{ DisplayName = 'Veeam Backup & Replication Console'; DisplayVersion = '13.1.1.18' } + } + } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Import-Module -Times 0 + } + + It 'requires an explicit target on a Console-only host' { + Mock Get-ItemProperty { [pscustomobject]@{ DisplayName = 'Veeam Backup & Replication Console'; DisplayVersion = '13.1.1.18' } } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Connect-VBRServer -Times 0 + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 -VBRServer fixture-vbr | Should -Be 0 + Should -Invoke Connect-VBRServer -Times 1 -ParameterFilter { $Server -eq 'fixture-vbr' } + } - It 'ignores active tasks that use an unselected proxy' { - Mock Write-ProxyLog {} - Mock Start-Sleep {} - Mock Get-VBRBackupSession { @([pscustomobject]@{ State = 'Working' }) } - Mock Get-VBRTaskSession { - @([pscustomobject]@{ - Status = 'InProgress' - Name = 'Other proxy task' - Info = [pscustomobject]@{ - WorkDetails = [pscustomobject]@{ SourceProxyId = 'other-proxy' } + It 'rejects ambiguous modules, manifest floors, editions, and conflicting loaded modules' -ForEach @( + @{ Failure = 'ambiguous' }; @{ Failure = 'minimum' }; @{ Failure = 'edition' }; @{ Failure = 'loaded' }; @{ Failure = 'missing' } + ) { + Mock Get-Module { + if ($ListAvailable -and $Failure -ne 'missing') { + [pscustomobject]@{ + Path = Join-Path $TestDrive 'Veeam.Backup.PowerShell.psd1' + PowerShellVersion = if ($Failure -eq 'minimum') { [version]'7.7' } else { [version]'7.0' } + CompatiblePSEditions = if ($Failure -eq 'edition') { @('Desktop') } else { @('Core') } + } + if ($Failure -eq 'ambiguous') { [pscustomobject]@{ Path = Join-Path $TestDrive 'other.psd1' } } } - }) + elseif (-not $ListAvailable -and $Failure -eq 'loaded') { [pscustomobject]@{ Path = Join-Path $TestDrive 'other.psd1' } } + } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Import-Module -Times 0 } - { Wait-ProxyTasksToDrain -ProxyObjects @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) -PollDelay 1 -DrainTimeoutMinutes 1 } | - Should -Not -Throw - Should -Invoke Start-Sleep -Times 0 - } -} + It 'rejects v13.1 on a runtime below its release floor' { + Mock Get-ProxyHostEnvironment { [pscustomobject]@{ IsWindows = $true; Is64BitProcess = $true; Edition = 'Core'; Version = [version]'7.4.7' } } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Import-Module -Times 0 + } -Describe 'sccmpatch.ps1 Post stage' { + It 'preserves an existing session and refuses maintenance' { + Mock Get-VBRServerSession { [pscustomobject]@{} } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Connect-VBRServer -Times 0 + Should -Invoke Disconnect-VBRServer -Times 0 + Should -Invoke Get-VBRViProxy -Times 0 + } - It 'starts services, re-enables proxies, and returns 3010 for a pending reboot' { - Mock Write-ProxyLog {} - Mock Import-Module {} - Mock Get-VBRViProxy { @([pscustomobject]@{ Id = 'proxy-1'; Name = 'Proxy1' }) } - Mock Invoke-Command {} - Mock Enable-VBRViProxy {} - Mock Test-Path { $true } + It 'refuses invalid or mismatched connected builds and disconnects its own session' -ForEach @( + @{ Build = '' }; @{ Build = 'garbage' }; @{ Build = '13.0.0.4967' }; @{ Build = '12.3.2.4854' } + ) { + Mock Get-VBRBackupServerInfo { [pscustomobject]@{ Build = $Build } } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Get-VBRViProxy -Times 0 + Should -Invoke Disconnect-VBRServer -Times 1 + } - $result = Invoke-ProxyMaintenance -Stage 'Post' -Proxies @('Proxy1') -PollDelay 1 -DrainTimeoutMinutes 1 + It 'allows a revision difference accepted by Connect and retains the maintenance exit code if cleanup fails' { + Mock Get-VBRBackupServerInfo { [pscustomobject]@{ Build = [version]'13.1.1.99' } } + Mock Disable-VBRViProxy { throw 'disable failed' } + Mock Disconnect-VBRServer { throw 'disconnect failed' } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 20 + Should -Invoke Disconnect-VBRServer -Times 1 + } - $result | Should -Be 3010 - Should -Invoke Invoke-Command -Times 1 - Should -Invoke Enable-VBRViProxy -Times 1 + It 'does not claim ownership when Connect fails' { + Mock Connect-VBRServer { throw 'connection failed' } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 99 + Should -Invoke Get-VBRViProxy -Times 0 + Should -Invoke Disconnect-VBRServer -Times 0 + } + + It 'validates the build before proxy discovery and imports the selected absolute module' { + $script:StartupOrder = @() + Mock Import-Module { $script:StartupOrder += 'import' } + Mock Connect-VBRServer { $script:StartupOrder += 'connect' } + Mock Get-VBRBackupServerInfo { $script:StartupOrder += 'build'; [pscustomobject]@{ Build = [version]'13.1.1.18' } } + Mock Get-VBRViProxy { $script:StartupOrder += 'proxy'; @() } + Invoke-ProxyMaintenance -Stage Pre -Proxies Proxy1 -PollDelay 1 -DrainTimeoutMinutes 1 | Should -Be 10 + ($script:StartupOrder -join ',') | Should -Be 'import,connect,build,proxy' + Should -Invoke Import-Module -Times 1 -ParameterFilter { $Name -eq (Join-Path $TestDrive 'Veeam.Backup.PowerShell.psd1') } + Should -Invoke Disconnect-VBRServer -Times 1 + } } + } From 38fab5e670e46579f27ca0711f232d0d098fa7df Mon Sep 17 00:00:00 2001 From: Chris Grady <17553614+cgfixit@users.noreply.github.com> Date: Sat, 3 Oct 2026 15:27:39 -0400 Subject: [PATCH 2/2] fix(ci): select matrix shell in job defaults --- .github/workflows/pester.yml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/.github/workflows/pester.yml b/.github/workflows/pester.yml index 4717c8f..325a6f0 100644 --- a/.github/workflows/pester.yml +++ b/.github/workflows/pester.yml @@ -72,6 +72,9 @@ jobs: matrix: shell: [powershell, pwsh] name: Pester (Windows ${{ matrix.shell }}) + defaults: + run: + shell: ${{ matrix.shell }} runs-on: windows-latest timeout-minutes: 10 steps: @@ -79,7 +82,6 @@ jobs: uses: actions/checkout@v4 - name: Validate PowerShell parser - shell: ${{ matrix.shell }} run: | $errors = $null $null = [System.Management.Automation.Language.Parser]::ParseFile( @@ -91,7 +93,6 @@ jobs: } - name: Ensure Pester 5 is available - shell: ${{ matrix.shell }} run: | $pester = Get-Module -ListAvailable -Name Pester | Where-Object { $_.Version.Major -ge 5 } | @@ -102,7 +103,6 @@ jobs: } - name: Run Pester tests - shell: ${{ matrix.shell }} run: | $config = New-PesterConfiguration $config.Run.Path = './tests'