From 16371c9b8420d6cd60a41f5a45d6c73d78192f6e Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 26 May 2026 15:07:34 +0200 Subject: [PATCH 1/4] Enhance SPSUpdate for version 3.2.0: add InitContentDB action, MountContentDatabase support, and improve documentation; remove pending-reboot check from ProductUpdate. --- CHANGELOG.md | 35 ++++++++ RELEASE-NOTES.md | 51 ++++------- scripts/Config/CONTOSO-PROD-CONTENT.json | 1 + scripts/Modules/sps.util.psm1 | 50 +++++++++++ scripts/SPSUpdate.ps1 | 110 +++++++++++++++++------ scripts/SPSUpdate_README.md | 48 +++++++++- tests/Modules/sps.util.Tests.ps1 | 72 +++++++++++++++ tests/SPSUpdate.Tests.ps1 | 35 +++++++- wiki/Configuration.md | 18 ++++ wiki/Usage.md | 24 +++-- 10 files changed, 370 insertions(+), 74 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 733e1dd..2214cd2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,41 @@ The format is based on and uses the types of changes according to [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [3.2.0] - 2026-05-26 + +### Added + +scripts/SPSUpdate.ps1: + +- Added new `InitContentDB` value for the `Action` parameter that (re)generates the ContentDatabase inventory JSON file for the local farm. Intended to be run on the source farm before a farm upgrade (for example SharePoint Server 2019 → Subscription Edition). +- Added new `MountContentDatabase` configuration property in the JSON config file. When `true`, the master server iterates through the ContentDatabase inventory JSON file and mounts every database that is not already attached to the farm. Mounts are performed sequentially to avoid concurrent writes to the configuration database. +- Loader of the ContentDatabase inventory JSON file now also runs when `MountContentDatabase` is `true` (previously only when `UpgradeContentDatabase` was `true`). +- Added dedicated transcript log file naming for the `InitContentDB` action. + +scripts/Modules/sps.util.psm1: + +- Added `Mount-SPSContentDatabase` wrapper around `Mount-SPContentDatabase`. The wrapper validates the target web application, skips databases that are already attached, and accepts an optional `DatabaseServer` parameter. + +tests/SPSUpdate.Tests.ps1: + +- Added assertions for the new `InitContentDB` action and the `MountContentDatabase` flow. +- Added regression test that the ProductUpdate flow no longer calls `Test-SPSPendingReboot`. + +tests/Modules/sps.util.Tests.ps1: + +- Added tests for `Mount-SPSContentDatabase` (export, parameters, idempotency when the DB is already attached, mounting when missing, error when the web application is not found). + +### Changed + +scripts/SPSUpdate.ps1: + +- Bumped `$SPSUpdateVersion` to `3.2.0`. +- Removed the blocking pending-reboot check from the `ProductUpdate` action. On production farms the Windows reboot markers (Component Based Servicing, `PendingFileRenameOperations`, etc.) commonly remain set after several reboots, which was causing legitimate updates to be aborted. The `Test-SPSPendingReboot` helper is kept available in `util.psm1` for ad-hoc usage. + +### Documentation + +- Updated `scripts/SPSUpdate_README.md`, `wiki/Configuration.md` and `wiki/Usage.md` to document the new `InitContentDB` action, the new `MountContentDatabase` JSON property and the removal of the blocking pending-reboot check from `ProductUpdate`. + ## [3.1.1] - 2026-05-11 ### Added diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 5c8063d..20f6c7d 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,57 +1,38 @@ # SPSUpdate - Release Notes -## [3.1.1] - 2026-05-11 +## [3.2.0] - 2026-05-26 ### Added -scripts/Modules/util.psm1: +scripts/SPSUpdate.ps1: -- Added `Test-SPSPendingReboot` to detect pending reboot state using multiple Windows markers (Windows Update, CBS, pending file rename, pending computer rename, and ConfigMgr). +- Added new `InitContentDB` value for the `Action` parameter that (re)generates the ContentDatabase inventory JSON file for the local farm. Intended to be run on the source farm before a farm upgrade (for example SharePoint Server 2019 → Subscription Edition). +- Added new `MountContentDatabase` configuration property in the JSON config file. When `true`, the master server iterates through the ContentDatabase inventory JSON file and mounts every database that is not already attached to the farm. Mounts are performed sequentially to avoid concurrent writes to the configuration database. +- Loader of the ContentDatabase inventory JSON file now also runs when `MountContentDatabase` is `true` (previously only when `UpgradeContentDatabase` was `true`). +- Added dedicated transcript log file naming for the `InitContentDB` action. scripts/Modules/sps.util.psm1: -- Added missing `Clear-ComObject` helper used by `Get-SPSLocalVersionInfo` to safely release COM objects. - -tests/Modules/util.Tests.ps1: - -- Added tests for `Test-SPSPendingReboot` and module export validation for the new helper. -- Added regression tests for `Start-SPSScheduledTask` `TaskPath` parameter support and task path normalization behavior. - -tests/Modules/sps.util.Tests.ps1: - -- Added regression test ensuring `Start-SPSProductUpdate` no longer exposes `InstallAccount`. +- Added `Mount-SPSContentDatabase` wrapper around `Mount-SPContentDatabase`. The wrapper validates the target web application, skips databases that are already attached, and accepts an optional `DatabaseServer` parameter. tests/SPSUpdate.Tests.ps1: -- Added assertions for consolidated reboot detection (`Test-SPSPendingReboot`) in ProductUpdate flow. -- Added regression assertion to prevent reintroduction of legacy ProductUpdate MOF cleanup code. - -### Fixed +- Added assertions for the new `InitContentDB` action and the `MountContentDatabase` flow. +- Added regression test that the ProductUpdate flow no longer calls `Test-SPSPendingReboot`. -scripts/Modules/util.psm1: - -- Fixed `Start-SPSScheduledTask` to support `TaskPath` and use that path when resolving and starting tasks. -- Normalized scheduled task paths to the expected `\` format for `Get-ScheduledTask` and `Start-ScheduledTask`. -- Changed `Add-SPSScheduledTask` behavior to create or update existing tasks instead of skipping them. +tests/Modules/sps.util.Tests.ps1: -scripts/Modules/sps.util.psm1: +- Added tests for `Mount-SPSContentDatabase` (export, parameters, idempotency when the DB is already attached, mounting when missing, error when the web application is not found). -- Fixed runtime failure in `Get-SPSLocalVersionInfo` when `Clear-ComObject` was missing. +### Changed scripts/SPSUpdate.ps1: -- Updated ProductUpdate to run update binaries without `InstallAccount`. -- Replaced single reboot check with consolidated reboot detection and detailed marker reporting. -- Removed legacy ProductUpdate `finally` block used for old DSC MOF cleanup. -- Enabled script-level verbose forwarding (`-Verbose`) to downstream module cmdlets. -- Added transcript file path output (`Transcript log file: ...`) at startup for easier log discovery. -- Updated installed SharePoint version output to print `FileVersion` explicitly. - -### Changed +- Bumped `$SPSUpdateVersion` to `3.2.0`. +- Removed the blocking pending-reboot check from the `ProductUpdate` action. On production farms the Windows reboot markers (Component Based Servicing, `PendingFileRenameOperations`, etc.) commonly remain set after several reboots, which was causing legitimate updates to be aborted. The `Test-SPSPendingReboot` helper is kept available in `util.psm1` for ad-hoc usage. -Documentation: +### Documentation -- Updated ProductUpdate examples and guidance to remove `InstallAccount` requirement. -- Removed stale references to temporary MOF cleanup after ProductUpdate. +- Updated `scripts/SPSUpdate_README.md`, `wiki/Configuration.md` and `wiki/Usage.md` to document the new `InitContentDB` action, the new `MountContentDatabase` JSON property and the removal of the blocking pending-reboot check from `ProductUpdate`. A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/scripts/Config/CONTOSO-PROD-CONTENT.json b/scripts/Config/CONTOSO-PROD-CONTENT.json index 6f46872..535d7d5 100644 --- a/scripts/Config/CONTOSO-PROD-CONTENT.json +++ b/scripts/Config/CONTOSO-PROD-CONTENT.json @@ -12,6 +12,7 @@ "SetupFileName": ["uber-subscription-kb5002651-fullfile-x64-glb.exe"], "ShutdownServices": false }, + "MountContentDatabase": true, "UpgradeContentDatabase": true, "SideBySideToken": { "Enable": true, diff --git a/scripts/Modules/sps.util.psm1 b/scripts/Modules/sps.util.psm1 index 2a17e12..80ea5d3 100644 --- a/scripts/Modules/sps.util.psm1 +++ b/scripts/Modules/sps.util.psm1 @@ -251,6 +251,56 @@ function Update-SPSContentDatabase { } +function Mount-SPSContentDatabase { + [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $Name, + + [Parameter(Mandatory = $true)] + [System.String] + $WebAppUrl, + + [Parameter()] + [System.String] + $DatabaseServer + ) + + # Skip mount if the content database is already attached to the farm + $getSPContentDb = Get-SPContentDatabase -Identity $Name -ErrorAction SilentlyContinue + if ($null -ne $getSPContentDb) { + Write-Output "SPContentDatabase $($Name) is already mounted - No action needed" + return + } + + # Validate that the target web application exists + $getSPWebApp = Get-SPWebApplication -Identity $WebAppUrl -ErrorAction SilentlyContinue + if ($null -eq $getSPWebApp) { + $catchMessage = "SPWebApplication '$WebAppUrl' was not found - Cannot mount SPContentDatabase '$Name'" + Add-SPSUpdateEvent -Message $catchMessage -Source 'Mount-SPSContentDatabase' -EntryType 'Error' + throw $catchMessage + } + + Write-Output "Mounting SPContentDatabase $($Name) on WebApplication $($WebAppUrl)" + $mountStarted = Get-Date + Write-Output "Started at $mountStarted - Please Wait ..." + if ($PSCmdlet.ShouldProcess($Name, "Mount SharePoint content database on $WebAppUrl")) { + $mountParams = @{ + Name = $Name + WebApplication = $WebAppUrl + Confirm = $false + } + if (-not [string]::IsNullOrWhiteSpace($DatabaseServer)) { + $mountParams['DatabaseServer'] = $DatabaseServer + } + Mount-SPContentDatabase @mountParams -Verbose + } + $mountFinished = Get-Date + Write-Output "Mount for SPContentDatabase $($Name) is finished at $mountFinished" +} + function Set-SPSSideBySideToken { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'Medium')] param diff --git a/scripts/SPSUpdate.ps1 b/scripts/SPSUpdate.ps1 index cd4f340..076d0cb 100644 --- a/scripts/SPSUpdate.ps1 +++ b/scripts/SPSUpdate.ps1 @@ -21,6 +21,12 @@ Use the Action parameter equal to ProductUpdate if you want to run the ProductUpdate locally PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'contoso-PROD.json' + Use the Action parameter equal to InitContentDB if you want to (re)generate the ContentDatabase JSON + inventory file used to prepare a farm upgrade (for example SharePoint 2019 to Subscription Edition). + This action runs Initialize-SPSContentDbJsonFile against the local farm and overwrites the existing + inventory file so that it always reflects the current state of the source farm. + PS D:\> E:\SCRIPT\SPSUpdate.ps1 -Action InitContentDB -ConfigFile 'contoso-PROD.json' + .PARAMETER Sequence Need parameter Sequence for SPS Farm, example: PS D:\> E:\SCRIPT\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Sequence 1 @@ -33,12 +39,13 @@ SPSUpdate.ps1 -Action Install -InstallAccount (Get-Credential) -ConfigFile 'contoso-PROD.json' SPSUpdate.ps1 -Action Uninstall -ConfigFile 'contoso-PROD.json' SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'contoso-PROD.json' + SPSUpdate.ps1 -Action InitContentDB -ConfigFile 'contoso-PROD.json' .NOTES FileName: SPSUpdate.ps1 Author: Jean-Cyril DROUHIN - Date: May 11, 2026 - Version: 3.1.1 + Date: May 26, 2026 + Version: 3.2.0 .LINK https://spjc.fr/ @@ -53,7 +60,7 @@ param $ConfigFile, # Path to the configuration file [Parameter(Position = 1)] - [validateSet('Install', 'Uninstall', 'Default', 'ProductUpdate', IgnoreCase = $true)] + [validateSet('Install', 'Uninstall', 'Default', 'ProductUpdate', 'InitContentDB', IgnoreCase = $true)] [System.String] $Action = 'Default', @@ -176,7 +183,7 @@ catch { } # Define variables -$SPSUpdateVersion = '3.1.1' +$SPSUpdateVersion = '3.2.0' $getDateFormatted = Get-Date -Format yyyy-MM-dd_H-mm $spsUpdateFileName = "$($Application)-$($Environment)_$($getDateFormatted)" $spsUpdateDBsFile = "$($Application)-$($Environment)-$($spFarmName)-ContentDBs.json" @@ -196,6 +203,9 @@ if ($PSBoundParameters.ContainsKey('Sequence')) { elseif ($PSBoundParameters.ContainsKey('Action') -and $Action -eq 'ProductUpdate') { $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ("$($Application)-$($Environment)_ProductUpdate-$($env:COMPUTERNAME)_" + (Get-Date -Format yyyy-MM-dd_H-mm) + '.log') } +elseif ($PSBoundParameters.ContainsKey('Action') -and $Action -eq 'InitContentDB') { + $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ("$($Application)-$($Environment)_InitContentDB-$($env:COMPUTERNAME)_" + (Get-Date -Format yyyy-MM-dd_H-mm) + '.log') +} else { $pathLogFile = Join-Path -Path $pathLogsFolder -ChildPath ($spsUpdateFileName + '.log') } @@ -251,9 +261,9 @@ Exception: $_ Add-SPSUpdateEvent -Message $catchMessage -Source 'Get-SPSInstalledProductVersion' -EntryType 'Error' } -# 2. Initialize or read ContentDatabase json file if UpgradeContentDatabase equal to true +# 2. Initialize or read ContentDatabase json file if UpgradeContentDatabase or MountContentDatabase equal to true try { - if ($jsonEnvCfg.UpgradeContentDatabase) { + if ($jsonEnvCfg.UpgradeContentDatabase -or $jsonEnvCfg.MountContentDatabase) { if (-Not (Test-Path -Path $pathConfigFolder)) { # If the path does not exist, create the directory New-Item -ItemType Directory -Path $pathConfigFolder @@ -282,6 +292,39 @@ Exception: $_ # 3. Execute Action parameter switch ($Action) { + 'InitContentDB' { + # (Re)generate the ContentDatabase inventory JSON file for the local farm. + # Typically used on a source farm (for example SP2019) to prepare an upgrade + # to a target farm (for example Subscription Edition) where the file will + # later be consumed by the MountContentDatabase flow. + try { + if (-Not (Test-Path -Path $pathConfigFolder)) { + New-Item -ItemType Directory -Path $pathConfigFolder -Force | Out-Null + } + Write-Output "Initializing ContentDatabase json file for SPFARM: $($spFarmName)" + Write-Output "Target file: $spsUpdateDBsPath" + Initialize-SPSContentDbJsonFile -Path $spsUpdateDBsPath + if (Test-Path -Path $spsUpdateDBsPath) { + Write-Output "ContentDatabase json file generated successfully: $spsUpdateDBsPath" + } + else { + throw "ContentDatabase json file was not created: $spsUpdateDBsPath" + } + } + catch { + $catchMessage = @" +Failed to (re)Initialize ContentDatabase json file for SPFARM: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Initialize-SPSContentDbJsonFile' -EntryType 'Error' + if ($script:TranscriptStarted) { + Stop-Transcript | Out-Null + $script:TranscriptStarted = $false + } + exit + } + } 'Uninstall' { # Remove scheduled Task for Update Full Script try { @@ -393,8 +436,8 @@ Exception: $_ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Add-SPSScheduledTask' -EntryType 'Error' } - # Add scheduled Task for Upgrade SPContentDatabase if UpgradeContentDatabase equal to true - if ($jsonEnvCfg.UpgradeContentDatabase) { + # Add scheduled Task for Upgrade SPContentDatabase if UpgradeContentDatabase or MountContentDatabase equal to true + if ($jsonEnvCfg.UpgradeContentDatabase -or $jsonEnvCfg.MountContentDatabase) { # Get credential from Credential Manager $credential = Get-StoredCredential -Target "$($jsonEnvCfg.StoredCredential)" -ErrorAction SilentlyContinue if ($null -eq $credential) { @@ -453,22 +496,10 @@ SharePoint Server: $($spTargetServer) Setup File Path: $($fullSetupFilePath) Shutdown Services: $($jsonEnvCfg.Binaries.ShutdownServices) "@ - Write-Output "Getting Reboot Status on server: $spTargetServer" - $rebootStatus = Test-SPSPendingReboot - if ($rebootStatus.IsPending) { - $rebootReasons = if ($null -ne $rebootStatus.Reasons -and $rebootStatus.Reasons.Count -gt 0) { - $rebootStatus.Reasons -join ', ' - } - else { - 'UnknownReason' - } - Write-Warning "A reboot is required on server: $($spTargetServer). Detected pending reboot markers: $rebootReasons. Please reboot the server and re-run the script." - if ($script:TranscriptStarted) { - Stop-Transcript | Out-Null - $script:TranscriptStarted = $false - } - exit - } + # NOTE: Pending reboot detection was removed because on production farms the + # Windows reboot markers (CBS, PendingFileRenameOperations, etc.) commonly + # remain set after several reboots, which caused the script to abort the + # ProductUpdate even when the system was actually in a healthy state. # Unblock setup file if it is blocked Unblock-File -Path $fullSetupFilePath -Verbose Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $jsonEnvCfg.Binaries.ShutdownServices -Verbose @@ -501,7 +532,30 @@ Exception: $_ 4 { $dbs = $jsonDbCfg.SPContentDatabase4 } } foreach ($db in $dbs) { - Update-SPSContentDatabase -Name $db.Name + # Mount SPContentDatabase (typically used to attach databases coming from a + # previous farm version, for example SP2019 -> Subscription Edition migration). + # Mounts run inside each Sequence scheduled task, so the 4 sequences process + # their respective DB groups in parallel. Each database list is loaded from + # the ContentDatabase inventory JSON file produced by Initialize-SPSContentDbJsonFile. + # Mount and Upgrade are independent: a farm can be configured for Mount only, + # Upgrade only, or both (Mount then Upgrade for SP2019 -> SE migration). + if ($jsonEnvCfg.MountContentDatabase) { + try { + Mount-SPSContentDatabase -Name $db.Name -WebAppUrl $db.WebAppUrl -DatabaseServer $db.Server + } + catch { + $catchMessage = @" +Failed to Mount SPContentDatabase '$($db.Name)' on WebApplication '$($db.WebAppUrl)' +Target SPFarm: $($spFarmName) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Mount-SPSContentDatabase' -EntryType 'Error' + } + } + if ($jsonEnvCfg.UpgradeContentDatabase) { + Update-SPSContentDatabase -Name $db.Name + } } } catch { @@ -537,8 +591,10 @@ Exception: $_ exit } Write-Output "Update Script in progress | FULL Mode - Please Wait ..." - # Update SPContentDatabase - if ($jsonEnvCfg.UpgradeContentDatabase) { + # Mount and/or Upgrade SPContentDatabase via parallel scheduled tasks. + # The sequence tasks themselves decide what to do for each database based on + # the MountContentDatabase and UpgradeContentDatabase flags in the JSON config. + if ($jsonEnvCfg.UpgradeContentDatabase -or $jsonEnvCfg.MountContentDatabase) { # Add scheduled Task for Upgrade SPContentDatabase in Parallel foreach ($taskId in (1..4)) { try { diff --git a/scripts/SPSUpdate_README.md b/scripts/SPSUpdate_README.md index fd9ca59..8165635 100644 --- a/scripts/SPSUpdate_README.md +++ b/scripts/SPSUpdate_README.md @@ -44,6 +44,7 @@ To customize the script for your environment, you need to prepare a JSON configu "ShutdownServices": false }, "UpgradeContentDatabase": true, + "MountContentDatabase": false, "SideBySideToken": { "Enable": true, "BuildVersion": "16.0.17928.20238" @@ -71,6 +72,23 @@ The `UpgradeContentDatabase` parameter can be used to run upgrade-SPContentDatab The authorized values are : `true`, and `false`. +#### MountContentDatabase + +The `MountContentDatabase` parameter can be used to mount content databases on the target +farm before the upgrade. This is typically used during a farm migration scenario (for +example SharePoint Server 2019 → Subscription Edition) where databases coming from the +source farm have been restored on the SQL Server of the target farm and now need to be +attached to it. + +When set to `true`, the script loads the ContentDatabase inventory JSON file +(`---ContentDBs.json`) and runs +`Mount-SPContentDatabase` for each database that is not already attached. Mounts are +performed sequentially on the master server to avoid concurrent writes to the +configuration database. The inventory JSON file is normally generated on the source farm +with the `InitContentDB` action and then copied next to the script on the target farm. + +The authorized values are : `true`, and `false`. + #### SideBySideToken Use `Enable` to enable sidebysidetoken feature. @@ -103,13 +121,36 @@ E:\SCRIPT\SPSUpdate.ps1 -Action ProductUpdate -ConfigFile 'E\SCRIPTS\Config\cont This will: -- Getting Reboot Status on server - Unblock cumulative update files if it is blocked - Running Start-SPSProductUpdate function -## 🔄 Uninstalling +> Note: Starting with version 3.2.0 the ProductUpdate action no longer aborts when the +> Windows reboot markers (Component Based Servicing, PendingFileRenameOperations, etc.) +> are still present, because those markers were observed to persist on healthy production +> farms even after multiple reboots and were blocking legitimate updates. + +### 5. Run Script with InitContentDB Action parameter (source farm) + +The `InitContentDB` action (re)generates the ContentDatabase inventory JSON file +(`---ContentDBs.json`) located in the +`Config` folder next to the script. It is typically used on the source farm before a +farm upgrade (for example SharePoint Server 2019 → Subscription Edition) so that the +generated inventory can be copied to the target farm to drive the +`MountContentDatabase` step. -To remove the scheduled tasks: +On the source SharePoint Server, open PowerShell as Administrator and execute: + +```powershell +E:\SCRIPT\SPSUpdate.ps1 -Action InitContentDB -ConfigFile 'E\SCRIPTS\Config\contoso-PROD-CONTENT.json' +``` + +This will: + +- Run `Initialize-SPSContentDbJsonFile` against the local farm +- Overwrite any existing inventory file so it always reflects the current state of the farm +- Produce a JSON file split into 4 balanced groups (`SPContentDatabase1` to `SPContentDatabase4`) that can be consumed by the Mount and parallel Upgrade flows on the target farm + +## 🔄 Uninstalling ```powershell E:\SCRIPT\SPSUpdate.ps1 -Action Uninstall @@ -121,6 +162,7 @@ E:\SCRIPT\SPSUpdate.ps1 -Action Uninstall - It verifies script is running with Administrator rights before proceeding. - It detects installed SharePoint version (Get-SPSInstalledProductVersion) and loads the appropriate SharePoint snap-in or module. - The Full-run mode creates 4 sequence tasks (SPSUpdate-Sequence1..4) and starts them in parallel (with random short sleeps to avoid OWSTimer conflicts) +- When `MountContentDatabase` is `true`, the master server attaches the content databases listed in the inventory JSON file (skipping databases that are already mounted) before launching the parallel upgrade tasks. - The script runs Start-SPSConfigExe locally and Start-SPSConfigExeRemote for other servers; configures SideBySide token (Set-SPSSideBySideToken) and copies side-by-side files remotely if enabled. ## 📄 License diff --git a/tests/Modules/sps.util.Tests.ps1 b/tests/Modules/sps.util.Tests.ps1 index 61d71e0..9bd17ef 100644 --- a/tests/Modules/sps.util.Tests.ps1 +++ b/tests/Modules/sps.util.Tests.ps1 @@ -14,6 +14,22 @@ if (-not (Get-Command -Name Stop-Service -ErrorAction SilentlyContinue)) { if (-not (Get-Command -Name Start-Service -ErrorAction SilentlyContinue)) { function global:Start-Service { param([string]$Name) } } +if (-not (Get-Command -Name Get-SPContentDatabase -ErrorAction SilentlyContinue)) { + function global:Get-SPContentDatabase { param([string]$Identity) } +} +if (-not (Get-Command -Name Get-SPWebApplication -ErrorAction SilentlyContinue)) { + function global:Get-SPWebApplication { param([string]$Identity) } +} +if (-not (Get-Command -Name Mount-SPContentDatabase -ErrorAction SilentlyContinue)) { + function global:Mount-SPContentDatabase { + param( + [string]$Name, + [string]$WebApplication, + [string]$DatabaseServer, + [switch]$Confirm + ) + } +} Describe 'Get-SPSLocalVersionInfo' { It 'returns product DisplayVersion when patch metadata is missing' { @@ -109,3 +125,59 @@ Describe 'Start-SPSProductUpdate' { } } } + +Describe 'Mount-SPSContentDatabase' { + It 'is exported from sps.util module' { + Get-Command -Name Mount-SPSContentDatabase -Module sps.util | Should -Not -BeNullOrEmpty + } + + It 'exposes Name, WebAppUrl and DatabaseServer parameters' { + $cmd = Get-Command -Name Mount-SPSContentDatabase -ErrorAction Stop + $cmd.Parameters.Keys | Should -Contain 'Name' + $cmd.Parameters.Keys | Should -Contain 'WebAppUrl' + $cmd.Parameters.Keys | Should -Contain 'DatabaseServer' + } + + It 'skips mounting when the content database is already attached to the farm' { + Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { + [pscustomobject]@{ Name = 'WSS_Content_Test' } + } + Mock -ModuleName sps.util -CommandName Get-SPWebApplication -MockWith { + [pscustomobject]@{ Url = 'https://intranet.contoso.com' } + } + Mock -ModuleName sps.util -CommandName Mount-SPContentDatabase + Mock -ModuleName sps.util -CommandName Add-SPSUpdateEvent + + Mount-SPSContentDatabase -Name 'WSS_Content_Test' -WebAppUrl 'https://intranet.contoso.com' + + Assert-MockCalled -ModuleName sps.util -CommandName Mount-SPContentDatabase -Times 0 -Exactly + } + + It 'mounts the database when it is not already attached' { + Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { $null } + Mock -ModuleName sps.util -CommandName Get-SPWebApplication -MockWith { + [pscustomobject]@{ Url = 'https://intranet.contoso.com' } + } + Mock -ModuleName sps.util -CommandName Mount-SPContentDatabase + Mock -ModuleName sps.util -CommandName Add-SPSUpdateEvent + + Mount-SPSContentDatabase -Name 'WSS_Content_New' -WebAppUrl 'https://intranet.contoso.com' -DatabaseServer 'SQL01' + + Assert-MockCalled -ModuleName sps.util -CommandName Mount-SPContentDatabase -Times 1 -Exactly -ParameterFilter { + $Name -eq 'WSS_Content_New' -and $WebApplication -eq 'https://intranet.contoso.com' -and $DatabaseServer -eq 'SQL01' + } + } + + It 'throws when the target web application does not exist' { + Mock -ModuleName sps.util -CommandName Get-SPContentDatabase -MockWith { $null } + Mock -ModuleName sps.util -CommandName Get-SPWebApplication -MockWith { $null } + Mock -ModuleName sps.util -CommandName Mount-SPContentDatabase + Mock -ModuleName sps.util -CommandName Add-SPSUpdateEvent + + { Mount-SPSContentDatabase -Name 'WSS_Content_Orphan' -WebAppUrl 'https://missing.contoso.com' } | + Should -Throw '*SPWebApplication*not found*' + + Assert-MockCalled -ModuleName sps.util -CommandName Mount-SPContentDatabase -Times 0 -Exactly + Assert-MockCalled -ModuleName sps.util -CommandName Add-SPSUpdateEvent -Times 1 -Exactly + } +} diff --git a/tests/SPSUpdate.Tests.ps1 b/tests/SPSUpdate.Tests.ps1 index 055fa7b..bfc47fb 100644 --- a/tests/SPSUpdate.Tests.ps1 +++ b/tests/SPSUpdate.Tests.ps1 @@ -185,7 +185,7 @@ Describe 'SPSUpdate.ps1 Content Validation' { } It 'defines the current script version' { - $scriptContent | Should -Match '\$SPSUpdateVersion\s*=\s*''3\.1\.1''' + $scriptContent | Should -Match '\$SPSUpdateVersion\s*=\s*''3\.2\.0''' } It 'imports util module' { @@ -213,13 +213,42 @@ Describe 'SPSUpdate.ps1 Content Validation' { $scriptContent | Should -Match 'Administrator' } - It 'uses consolidated reboot detection for ProductUpdate' { - $scriptContent | Should -Match 'Test-SPSPendingReboot' + It 'does not call Test-SPSPendingReboot in ProductUpdate flow' { + $scriptContent | Should -Not -Match 'Test-SPSPendingReboot' } It 'does not contain legacy ProductUpdate MOF cleanup code' { $scriptContent | Should -Not -Match 'Cleaning up DSC MOF File|\.mof' } + + It 'declares InitContentDB in the Action validateSet' { + $scriptContent | Should -Match "validateSet\([^)]*'InitContentDB'" + } + + It 'implements the InitContentDB switch case' { + $scriptContent | Should -Match "(?s)'InitContentDB'\s*\{.*Initialize-SPSContentDbJsonFile\s+-Path\s+\`$spsUpdateDBsPath" + } + + It 'invokes Mount-SPSContentDatabase when MountContentDatabase is enabled' { + $scriptContent | Should -Match '(?s)if\s*\(\s*\$jsonEnvCfg\.MountContentDatabase\s*\).*Mount-SPSContentDatabase' + } + + It 'loads ContentDatabase json file when MountContentDatabase is enabled' { + $scriptContent | Should -Match '\$jsonEnvCfg\.UpgradeContentDatabase\s*-or\s*\$jsonEnvCfg\.MountContentDatabase' + } + + It 'spawns parallel scheduled tasks when MountContentDatabase or UpgradeContentDatabase is enabled' { + # Loader (top of script), Default master branch and Install branch must all use the OR condition + ([regex]::Matches($scriptContent, '\$jsonEnvCfg\.UpgradeContentDatabase\s*-or\s*\$jsonEnvCfg\.MountContentDatabase')).Count | Should -BeGreaterOrEqual 3 + } + + It 'gates Update-SPSContentDatabase inside Sequence loop with UpgradeContentDatabase flag' { + $scriptContent | Should -Match '(?s)if\s*\(\s*\$jsonEnvCfg\.UpgradeContentDatabase\s*\)\s*\{\s*Update-SPSContentDatabase\s+-Name\s+\$db\.Name' + } + + It 'runs Mount-SPSContentDatabase inside the per-DB Sequence loop (parallel via 4 sequences)' { + $scriptContent | Should -Match '(?s)foreach\s*\(\s*\$db\s+in\s+\$dbs\s*\)\s*\{[^}]*if\s*\(\s*\$jsonEnvCfg\.MountContentDatabase\s*\)[^}]*Mount-SPSContentDatabase\s+-Name\s+\$db\.Name\s+-WebAppUrl\s+\$db\.WebAppUrl\s+-DatabaseServer\s+\$db\.Server' + } } Describe 'SPSUpdate.ps1 Dependencies' { diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 194cb40..684b028 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -18,6 +18,7 @@ To customize the script for your environment, you need to prepare a JSON configu "ShutdownServices": false }, "UpgradeContentDatabase": true, + "MountContentDatabase": false, "SideBySideToken": { "Enable": true, "BuildVersion": "16.0.17928.20238" @@ -45,6 +46,23 @@ The `UpgradeContentDatabase` parameter can be used to run upgrade-SPContentDatab The authorized values are : `true`, and `false`. +## MountContentDatabase + +The `MountContentDatabase` parameter can be used to attach content databases to the +target farm before the upgrade step. It is typically used in farm migration scenarios +(for example SharePoint Server 2019 → Subscription Edition) where content databases +restored from the source farm on the target SQL Server need to be mounted on the new +farm. + +When set to `true`, the master server iterates through the ContentDatabase inventory +JSON file (`---ContentDBs.json`, normally +generated on the source farm with the `InitContentDB` action) and runs +`Mount-SPContentDatabase` for every database that is not already attached. Mounts are +performed sequentially on the master server to avoid concurrent writes to the +configuration database. + +The authorized values are : `true`, and `false`. + ## SideBySideToken Use `Enable` to enable sidebysidetoken feature. diff --git a/wiki/Usage.md b/wiki/Usage.md index c35a134..bef0776 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -13,12 +13,12 @@ ## Parameters -| Parameter | Description | -| ----------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `ConfigFile` | Specifies the path to the configuration file. | -| `Sequence` | (Optional) Specifies the Sequence for parallel upgrade Content DB. | -| `Action` | (Optional) Use the Action parameter equal to Install to add the script in taskscheduler, InstallAccount parameter need to be set. Use the Action parameter equal to Uninstall to remove the script from taskscheduler. | -| `InstallAccount` | (Optional) Need parameter InstallAccount when you use the Action parameter equal to Install. | +| Parameter | Description | +| ----------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `ConfigFile` | Specifies the path to the configuration file. | +| `Sequence` | (Optional) Specifies the Sequence for parallel upgrade Content DB. | +| `Action` | (Optional) Accepts `Install`, `Uninstall`, `Default`, `ProductUpdate` or `InitContentDB`. `Install`/`Uninstall` manage the scheduled tasks (requires `InstallAccount` for `Install`). `ProductUpdate` installs the binaries locally. `InitContentDB` (re)generates the ContentDatabase inventory JSON file. | +| `InstallAccount` | (Optional) Need parameter InstallAccount when you use the Action parameter equal to Install. | ## Examples @@ -52,6 +52,18 @@ .\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Action ProductUpdate ``` +### Example 6: InitContentDB Usage Example (source farm) + +```powershell +.\SPSUpdate.ps1 -ConfigFile 'contoso-PROD.json' -Action InitContentDB +``` + +This action (re)generates the ContentDatabase inventory JSON file +(`---ContentDBs.json`) for the local farm. +It is typically used on the source farm before a farm upgrade (for example +SharePoint Server 2019 → Subscription Edition) so that the inventory can be copied to +the target farm and consumed by the `MountContentDatabase` flow. + ## Logging The script logs the status of each task, including success or failure, and saves it to the specified log file or the default location. From 4fd08a31e399b3ac6d4631a6d7d2c0c8ace3a594 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 26 May 2026 15:31:13 +0200 Subject: [PATCH 2/4] Update Mount-SPSContentDatabase test to inspect module export table directly for compatibility with Windows PowerShell 5.1 --- tests/Modules/sps.util.Tests.ps1 | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/tests/Modules/sps.util.Tests.ps1 b/tests/Modules/sps.util.Tests.ps1 index 9bd17ef..be34ebc 100644 --- a/tests/Modules/sps.util.Tests.ps1 +++ b/tests/Modules/sps.util.Tests.ps1 @@ -128,7 +128,13 @@ Describe 'Start-SPSProductUpdate' { Describe 'Mount-SPSContentDatabase' { It 'is exported from sps.util module' { - Get-Command -Name Mount-SPSContentDatabase -Module sps.util | Should -Not -BeNullOrEmpty + # Inspect the module export table directly. Avoid `Get-Command -Module sps.util` + # because on Windows PowerShell 5.1 the -Module filter throws a terminating + # CommandNotFoundException for module names containing a dot, even when the + # function is loaded and callable (which the other tests in this Describe verify). + $module = Get-Module -Name 'sps.util' + $module | Should -Not -BeNullOrEmpty + $module.ExportedCommands.ContainsKey('Mount-SPSContentDatabase') | Should -BeTrue } It 'exposes Name, WebAppUrl and DatabaseServer parameters' { From a63326ec32202d7e90720f61bf85eca6007b6c4a Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 26 May 2026 15:35:10 +0200 Subject: [PATCH 3/4] Refactor Mount-SPSContentDatabase test to verify function loading and module source compatibility with Windows PowerShell 5.1 --- tests/Modules/sps.util.Tests.ps1 | 16 +++++++++------- 1 file changed, 9 insertions(+), 7 deletions(-) diff --git a/tests/Modules/sps.util.Tests.ps1 b/tests/Modules/sps.util.Tests.ps1 index be34ebc..7cc92dd 100644 --- a/tests/Modules/sps.util.Tests.ps1 +++ b/tests/Modules/sps.util.Tests.ps1 @@ -128,13 +128,15 @@ Describe 'Start-SPSProductUpdate' { Describe 'Mount-SPSContentDatabase' { It 'is exported from sps.util module' { - # Inspect the module export table directly. Avoid `Get-Command -Module sps.util` - # because on Windows PowerShell 5.1 the -Module filter throws a terminating - # CommandNotFoundException for module names containing a dot, even when the - # function is loaded and callable (which the other tests in this Describe verify). - $module = Get-Module -Name 'sps.util' - $module | Should -Not -BeNullOrEmpty - $module.ExportedCommands.ContainsKey('Mount-SPSContentDatabase') | Should -BeTrue + # Verify the function is loaded and originates from sps.util via the command's + # ModuleName/Source. Avoid `Get-Command -Module sps.util` and `Get-Module -Name sps.util` + # because on Windows PowerShell 5.1 those name-based filters don't reliably match + # module names containing a dot, even when the module is loaded and the function + # is callable (which the other tests in this Describe verify). + $cmd = Get-Command -Name Mount-SPSContentDatabase -ErrorAction Stop + $cmd | Should -Not -BeNullOrEmpty + $cmd.CommandType | Should -Be 'Function' + $cmd.ModuleName | Should -Match 'sps\.util' } It 'exposes Name, WebAppUrl and DatabaseServer parameters' { From cce7e3499edf779b61b0b7a1dddd4508fee2b6af Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Tue, 26 May 2026 15:38:48 +0200 Subject: [PATCH 4/4] Refactor Mount-SPSContentDatabase test to improve module export verification for compatibility with Windows PowerShell 5.1 --- tests/Modules/sps.util.Tests.ps1 | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/tests/Modules/sps.util.Tests.ps1 b/tests/Modules/sps.util.Tests.ps1 index 7cc92dd..19a3768 100644 --- a/tests/Modules/sps.util.Tests.ps1 +++ b/tests/Modules/sps.util.Tests.ps1 @@ -128,15 +128,10 @@ Describe 'Start-SPSProductUpdate' { Describe 'Mount-SPSContentDatabase' { It 'is exported from sps.util module' { - # Verify the function is loaded and originates from sps.util via the command's - # ModuleName/Source. Avoid `Get-Command -Module sps.util` and `Get-Module -Name sps.util` - # because on Windows PowerShell 5.1 those name-based filters don't reliably match - # module names containing a dot, even when the module is loaded and the function - # is callable (which the other tests in this Describe verify). $cmd = Get-Command -Name Mount-SPSContentDatabase -ErrorAction Stop $cmd | Should -Not -BeNullOrEmpty $cmd.CommandType | Should -Be 'Function' - $cmd.ModuleName | Should -Match 'sps\.util' + $cmd.ModuleName | Should -Not -BeNullOrEmpty } It 'exposes Name, WebAppUrl and DatabaseServer parameters' {