diff --git a/CHANGELOG.md b/CHANGELOG.md index fade11c..78ee1bd 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,64 @@ 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). +## [4.2.0] - 2026-06-30 + +### Added + +- Near real-time patching dashboard. New public functions `Set-SPSUpdateStatus` / + `Get-SPSUpdateStatus` persist and read a shared, per-scope JSON status store + (atomic writes, each writer owns its file), `Get-SPSStatusCampaignPath` resolves + the campaign folder, and `Export-SPSUpdateProgressReport` renders a self-contained, + auto-refreshing HTML dashboard (overall state, per-phase sections, colored state + badges, per-item exit codes and per-sequence percentage). +- New optional `StatusStorePath` config key (UNC share) for the status store, with a + local `Results\status` fallback. +- New `-Action ResetStatus` to clear a campaign before a fresh patching round. +- `SPSUpdate.ps1` is instrumented to feed the store and regenerate the dashboard: + ProductUpdate per server (per-setup-file items), the four mount/upgrade sequences + (per-database items and a running percentage), the Configuration Wizard per server + (local and remote), and the side-by-side step. The master regenerates the dashboard + on every wait-loop iteration and writes a final completed dashboard. +- New `Test-SPSUpdateReadiness.ps1` pre-flight check (module, config, DPAPI secret, + elevation, status store write access, per-server CredSSP reachability). The status store + check probes write access **both as the current user and as the InstallAccount** (the + account that runs the sequence tasks), since the upgrade sequences only publish their + status — and therefore only appear on the dashboard — when the service account can write + to the share. +- `-Action ResetStatus` now creates the campaign folder and an empty "waiting" dashboard so + it can be opened in a browser before patching begins. +- The master keeps the dashboard live during the staggered sequence start (regenerated after + each task start and every ~10s during the 60-90s OWSTimer pause), not only once the wait + loop is reached. + +### Changed + +- Bumped the module manifest to `4.2.0` and exported the four new functions. +- Extended the shared HTML head helper with an optional meta-refresh and state-badge styles. +- Documented that the InstallAccount needs **Modify** on the status store share (SMB + NTFS) + for the upgrade phase to appear on the dashboard (Getting-Started, Configuration, Usage and + the offline guide). +- The dashboard now collapses finished scopes (Done/Skipped) by default and keeps active ones + (Running/Pending/Failed/Warning) expanded, using native `
`/`` (progressive + disclosure, accessible, no JS). Each collapsed scope still shows its badge, percentage and a + compact "N/M done" count on the summary line. +- The real process exit codes now flow to the dashboard: `Start-SPSProductUpdate` returns the + setup.exe exit code (shown in the ProductUpdate item's Exit column, e.g. `0`, `17022` = reboot + required), and the Configuration Wizard records the psconfig.exe exit code in its detail + (`PSConfig completed (exit 0)`), for the local and remote servers. + +### Fixed + +- The dashboard roll-up counted each unit of work once instead of summing both a scope and + its items (e.g. two servers each installing one binary now read "2 Done", not "4"). +- `Start-SPSProductUpdate` no longer leaks the `iisreset -start` Process object into its output + (it is piped to `Out-Null`), so its return value is a clean exit code. + +### Tests + +- Added cross-platform Pester suites for the status store round-trip and resilience, and + for the dashboard (running / failed / completed / empty / HTML-encoding). + ## [4.1.0] - 2026-06-29 ### Added diff --git a/RELEASE-NOTES.md b/RELEASE-NOTES.md index 4e76492..ac7dbbc 100644 --- a/RELEASE-NOTES.md +++ b/RELEASE-NOTES.md @@ -1,24 +1,56 @@ # SPSUpdate - Release Notes -## [4.1.0] - 2026-06-29 +## [4.2.0] - 2026-06-30 -This release adds a self-contained HTML report for the ContentDatabase inventory, in the -same spirit as the SPSUserSync reports. It builds on the v4.0.0 modernization and is fully -backward compatible. +This release adds a near real-time patching dashboard: while a cumulative update is rolled +out across the farm, SPSUpdate records the progress of every phase into a shared status +store and the master assembles a self-contained, auto-refreshing HTML dashboard. ### Added -- New public function `Export-SPSUpdateDbReport` that renders the ContentDatabase inventory (`---ContentDBs.json`) as a self-contained, offline HTML report: summary cards (total databases, total size, balance spread), the per-sequence LPT distribution (count / size / percentage bars), and a sortable, filterable table of every database. Inventories generated before v4.1.0 (no size) still render and fall back to distributing by database count. -- `SPSUpdate.ps1` now writes the HTML report under a new `Results\` folder whenever the inventory is (re)generated (the `InitContentDB` action and the Default-mode prime). Report failures warn but never block the run. -- Private report helpers: `ConvertTo-SPSHtmlEncoded`, `Get-SPSReportCardHtml`, `Get-SPSReportHtmlHead`, `Get-SPSReportDistributionHtml`, `Get-SPSReportHtmlScript`. +- Status store and dashboard functions: `Set-SPSUpdateStatus` / `Get-SPSUpdateStatus` + (atomic per-scope JSON store, each writer owns its file), `Get-SPSStatusCampaignPath` + (resolves `\--`), and `Export-SPSUpdateProgressReport` (renders a + self-contained HTML dashboard with overall state, per-phase sections, colored state + badges, per-item exit codes and per-sequence percentage; meta-refresh while running). +- New optional `StatusStorePath` config key (UNC share) with a local `Results\status` + fallback. +- New `-Action ResetStatus` to clear a campaign before a fresh patching round. +- `SPSUpdate.ps1` is instrumented end-to-end: ProductUpdate per server (per-setup-file + items), the four mount/upgrade sequences (per-database items and a running percentage), + the Configuration Wizard per server (local and remote), and side-by-side. The master + regenerates the dashboard on every wait-loop iteration and writes a final completed + dashboard with auto-refresh off. +- New `Test-SPSUpdateReadiness.ps1` pre-flight check (module, config, DPAPI secret, + elevation, status store write access, per-server CredSSP reachability). The status store + check probes write access **both as the current user and as the InstallAccount**, since the + upgrade sequences run as the service account and only appear on the dashboard when it can + write to the share. +- The dashboard collapses finished scopes (Done/Skipped) by default and keeps active ones + expanded (native `
`/``, accessible, no JS); each collapsed line still + shows its badge, percentage and an `N/M done` count. +- Real process exit codes are surfaced: the ProductUpdate item's Exit column shows the + setup.exe code (`0`, `17022` = reboot required, ...), and the Configuration Wizard records + the psconfig.exe code in its detail (`PSConfig completed (exit 0)`). + +### How to use + +1. Set `StatusStorePath` to a UNC share writable by the InstallAccount from every server + (grant it **Modify** on the SMB share and NTFS — the sequence tasks run as that account). +2. Run `Test-SPSUpdateReadiness.ps1` to confirm the environment (both write probes green). +3. `SPSUpdate.ps1 -ConfigFile '.psd1' -Action ResetStatus` to start a clean campaign. +4. Open `\--\_dashboard.html` in a browser. +5. Run `-Action ProductUpdate` on each server, then the default master run; watch the + dashboard update itself. ### Changed -- `Initialize-SPSContentDbJsonFile` now persists `SizeInBytes` and `SizeInMB` for each database in the inventory JSON. This is backward compatible: the mount/upgrade flow still reads only `Name`, `WebAppUrl` and `Server`. -- Bumped the module manifest to `4.1.0` and exported `Export-SPSUpdateDbReport`. +- Bumped the module manifest to `4.2.0` and exported the new functions. ### Notes -- An inventory produced by v4.0.0 (without size) renders correctly; regenerate it with `-Action InitContentDB` to get the size columns and size-based balancing in the report. +- Validated end-to-end on a real three-server Subscription Edition farm with an actual + cumulative update (binary install, parallel content-database upgrade, and the post-setup + Configuration Wizard, all reflected live on the dashboard). A full list of changes in each version can be found in the [change log](CHANGELOG.md) diff --git a/src/Config/CONTOSO-PROD.example.psd1 b/src/Config/CONTOSO-PROD.example.psd1 index c307dd9..4fec29f 100644 --- a/src/Config/CONTOSO-PROD.example.psd1 +++ b/src/Config/CONTOSO-PROD.example.psd1 @@ -39,6 +39,14 @@ # Possible values : any key present in secrets.psd1, e.g. 'PROD-ADM'. CredentialKey = 'PROD-ADM' + # StatusStorePath : OPTIONAL UNC share where every server writes its patching + # progress so the live HTML dashboard can be assembled (near real-time tracking, + # v4.2.0+). It must be writable by the InstallAccount from every farm server. + # Leave empty/omit to fall back to the local Results\status folder (in that case + # ProductUpdate runs on other servers are not captured in the master dashboard). + # Possible values : '' or a UNC path, e.g. '\\fileserver\spsupdate-status'. + StatusStorePath = '\\fileserver\spsupdate-status' + # --- Binaries (REQUIRED block; used by -Action ProductUpdate) --------------------- Binaries = @{ # ProductUpdate : allow the binary installation step. @@ -61,7 +69,7 @@ # ShutdownServices : stop Search/Timer/IIS services during install to speed it up # (they are restored to their prior state afterwards). # Possible values : $true | $false. Default if omitted: $true - ShutdownServices = $false + ShutdownServices = $true } # --- Content database handling (OPTIONAL) ---------------------------------------- diff --git a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 index d5118b2..d049921 100644 --- a/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 +++ b/src/Modules/SPSUpdate.Common/Private/Get-SPSReportHtmlHead.ps1 @@ -9,7 +9,11 @@ ( [Parameter(Mandatory = $true)] [System.String] - $Title + $Title, + + [Parameter()] + [System.Int32] + $RefreshSeconds = 0 ) $css = @' @@ -43,8 +47,29 @@ tbody tr:nth-child(even){background:var(--zebra)} .pager{display:flex;gap:8px;align-items:center;font-size:12px} .pager button{padding:4px 10px;border:1px solid var(--line);background:#fff;border-radius:4px;cursor:pointer} .pager button:disabled{opacity:.4;cursor:default} +.badge{display:inline-block;padding:2px 8px;border-radius:10px;font-size:11px;font-weight:600;color:#fff} +.badge.Pending{background:#9aa4ad} +.badge.Running{background:#1f6fb2} +.badge.Done{background:#2e9b57} +.badge.Failed{background:#c0392b} +.badge.Warning{background:#c19c00;color:#222} +.badge.Skipped{background:#cfd6dc;color:#333} +.phase{margin-top:18px} +.scope{border:1px solid var(--line);border-radius:6px;padding:10px 12px;margin:8px 0;background:#fff} +details.scope>summary{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--brand-dark);cursor:pointer;list-style:none;outline:none} +details.scope>summary::-webkit-details-marker{display:none} +details.scope>summary::before{content:'';flex:0 0 auto;width:0;height:0;border-left:5px solid var(--muted);border-top:4px solid transparent;border-bottom:4px solid transparent;transition:transform .15s ease;margin-right:2px} +details.scope[open]>summary::before{transform:rotate(90deg)} +details.scope>summary:hover{color:var(--brand)} +details.scope>summary:focus-visible{box-shadow:0 0 0 2px #cfe0ef;border-radius:4px} +.scope-head{display:flex;align-items:center;gap:8px;font-size:13px;font-weight:600;color:var(--brand-dark)} +.scope-detail{color:var(--muted);font-size:11px;margin-top:6px} +.count{color:var(--muted);font-weight:600} +.items{margin-top:6px} +.live{color:#2e9b57;font-size:11px} .footer{color:var(--muted);font-size:11px;margin-top:24px;border-top:1px solid var(--line);padding-top:8px} '@ - return "$Title" + $refreshTag = if ($RefreshSeconds -gt 0) { "" } else { '' } + return "$refreshTag$Title" } diff --git a/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 new file mode 100644 index 0000000..be2868a --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Export-SPSUpdateProgressReport.ps1 @@ -0,0 +1,288 @@ +function Export-SPSUpdateProgressReport { + <# + .SYNOPSIS + Renders the live patching progress dashboard from the status store. + + .DESCRIPTION + Export-SPSUpdateProgressReport reads every status scope of a campaign (via + Get-SPSUpdateStatus) and renders a single, self-contained HTML dashboard that + shows the overall patching progress and the detail of each phase + (ProductUpdate per server, the four upgrade/mount sequences, the post-setup + Configuration Wizard per server, and side-by-side). It is regenerated + periodically by the master during the run, and the page carries a meta-refresh + so an open browser updates on its own. + + The dashboard is fully server-rendered (no fetch), so it works from a file + share opened over file:// as well as over HTTP. All values are HTML-encoded. + + Returns the path of the dashboard that was written. + + .PARAMETER CampaignPath + Folder of the patching campaign to read (the campaign status files live here, + and the dashboard is written here by default). + + .PARAMETER OutputFile + Destination path of the dashboard. Defaults to '_dashboard.html' in CampaignPath. + + .PARAMETER Title + Heading shown at the top. Defaults to a generic title. + + .PARAMETER EnvName + Environment label shown in the metadata line. + + .PARAMETER AppCode + Application code shown in the metadata line. + + .PARAMETER FarmName + Farm label shown in the metadata line. + + .PARAMETER RefreshSeconds + Meta-refresh interval (seconds). 0 disables auto-refresh. Default 15. + + .PARAMETER Completed + Mark the campaign as completed: disables the auto-refresh and shows a final state. + + .PARAMETER Version + SPSUpdate version stamped in the footer. Defaults to the module version. + + .EXAMPLE + Export-SPSUpdateProgressReport -CampaignPath $c -EnvName 'PROD' -AppCode 'contoso' -FarmName 'CONTENT' + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $CampaignPath, + + [Parameter()] + [System.String] + $OutputFile, + + [Parameter()] + [System.String] + $Title, + + [Parameter()] + [System.String] + $EnvName, + + [Parameter()] + [System.String] + $AppCode, + + [Parameter()] + [System.String] + $FarmName, + + [Parameter()] + [System.Int32] + $RefreshSeconds = 15, + + [Parameter()] + [switch] + $Completed, + + [Parameter()] + [System.String] + $Version + ) + + if ([string]::IsNullOrEmpty($OutputFile)) { + $OutputFile = Join-Path -Path $CampaignPath -ChildPath '_dashboard.html' + } + if ([string]::IsNullOrEmpty($Title)) { $Title = 'SPSUpdate - Patching Progress' } + if ([string]::IsNullOrEmpty($Version)) { + $moduleVersion = (Get-Module -Name SPSUpdate.Common -ErrorAction SilentlyContinue).Version + $Version = if ($null -ne $moduleVersion) { $moduleVersion.ToString() } else { 'unknown' } + } + + $scopes = @(Get-SPSUpdateStatus -CampaignPath $CampaignPath) + + # Helper: a colored state badge. + $badge = { + param($state) + $s = if ([string]::IsNullOrEmpty($state)) { 'Pending' } else { "$state" } + $encoded = ConvertTo-SPSHtmlEncoded -Value $s + "$encoded" + } + + # ---- Overall roll-up ---------------------------------------------------------- + # Two separate concerns: + # * Card counts: each leaf unit of work counted once - the items when a scope has any + # (each binary, each database), otherwise the scope state itself (e.g. the Wizard, + # which has a per-server state but no items). This avoids double counting. + # * Overall state: derived from BOTH scope states and item states, so a scope that is + # still Running/Pending keeps the overall "Running" even when the items recorded so + # far all happen to be Done (e.g. during the staggered sequence start). + $allItemStates = New-Object System.Collections.Generic.List[string] + $overallStates = New-Object System.Collections.Generic.List[string] + foreach ($sc in $scopes) { + if ($sc.State) { $overallStates.Add("$($sc.State)") } + $scopeItems = @($sc.Items) + if ($scopeItems.Count -gt 0) { + foreach ($it in $scopeItems) { + if ($it.State) { $allItemStates.Add("$($it.State)"); $overallStates.Add("$($it.State)") } + } + } + elseif ($sc.State) { + $allItemStates.Add("$($sc.State)") + } + } + $countFailed = @($allItemStates | Where-Object { $_ -eq 'Failed' }).Count + $countRunning = @($allItemStates | Where-Object { $_ -eq 'Running' }).Count + $countDone = @($allItemStates | Where-Object { $_ -eq 'Done' }).Count + + $hasFailed = @($overallStates | Where-Object { $_ -eq 'Failed' }).Count -gt 0 + $hasActive = @($overallStates | Where-Object { $_ -eq 'Running' -or $_ -eq 'Pending' }).Count -gt 0 + $hasWarning = @($overallStates | Where-Object { $_ -eq 'Warning' }).Count -gt 0 + + $overall = if ($scopes.Count -eq 0) { 'Pending' } + elseif ($hasFailed) { 'Failed' } + elseif ($hasActive) { 'Running' } + elseif ($hasWarning) { 'Warning' } + else { 'Done' } + if ($Completed -and $overall -eq 'Running') { $overall = 'Done' } + + $serverCount = @($scopes | Select-Object -ExpandProperty Server -Unique | Where-Object { $_ }).Count + + # ---- Summary cards ------------------------------------------------------------ + $cards = @( + "
$(& $badge $overall)
Overall state
" + (Get-SPSReportCardHtml -Value $serverCount -Label 'Servers') + (Get-SPSReportCardHtml -Value $countDone -Label 'Done') + (Get-SPSReportCardHtml -Value $countRunning -Label 'Running') + (Get-SPSReportCardHtml -Value $countFailed -Label 'Failed') + ) -join '' + + # ---- Phase sections ----------------------------------------------------------- + $phaseOrder = @('ProductUpdate', 'Mount', 'Upgrade', 'Sequence', 'Wizard', 'SideBySide') + $phaseLabels = @{ + ProductUpdate = 'Product update (binaries)' + Mount = 'Content database mount' + Upgrade = 'Content database upgrade' + Sequence = 'Content database sequences' + Wizard = 'Configuration Wizard (PSConfig)' + SideBySide = 'Side-by-side token' + } + + $sections = '' + $presentPhases = @($scopes | Select-Object -ExpandProperty Phase -Unique) + $orderedPhases = @($phaseOrder | Where-Object { $presentPhases -contains $_ }) + $orderedPhases += @($presentPhases | Where-Object { $phaseOrder -notcontains $_ }) + + foreach ($phase in $orderedPhases) { + $phaseScopes = @($scopes | Where-Object { $_.Phase -eq $phase } | Sort-Object Server, Scope) + if ($phaseScopes.Count -eq 0) { continue } + $label = if ($phaseLabels.ContainsKey($phase)) { $phaseLabels[$phase] } else { $phase } + $sections += "

$(ConvertTo-SPSHtmlEncoded -Value $label)

" + + foreach ($sc in $phaseScopes) { + $encServer = ConvertTo-SPSHtmlEncoded -Value "$($sc.Server)" + $encScope = ConvertTo-SPSHtmlEncoded -Value "$($sc.Scope)" + $encDetail = ConvertTo-SPSHtmlEncoded -Value "$($sc.Detail)" + $pctText = if ($null -ne $sc.Percent -and "$($sc.Percent)" -ne '') { " · $([int]$sc.Percent)%" } else { '' } + $updated = '' + if ($sc.UpdatedAt) { + try { $updated = " · updated $([datetime]::Parse($sc.UpdatedAt).ToString('HH:mm:ss'))" } catch { $updated = '' } + } + + $items = @($sc.Items) + + # Compact item summary shown on the (always visible) summary line, e.g. "3/4 done". + $itemSummary = '' + if ($items.Count -gt 0) { + $itemsDone = @($items | Where-Object { "$($_.State)" -eq 'Done' }).Count + $itemSummary = " · $itemsDone/$($items.Count) done" + } + + # Progressive disclosure: collapse finished scopes (Done/Skipped) by default so the + # operator sees what still needs attention without scrolling; keep active ones open. + $scopeState = "$($sc.State)" + $isFinished = ($scopeState -eq 'Done' -or $scopeState -eq 'Skipped') + $openAttr = if ($isFinished) { '' } else { ' open' } + + $sections += "
$(& $badge $sc.State) $encServer / $encScope$pctText$itemSummary" + if (-not [string]::IsNullOrEmpty($encDetail)) { + $sections += "
$encDetail$updated
" + } + elseif (-not [string]::IsNullOrEmpty($updated)) { + $sections += "
$($updated.TrimStart(' ·'))
" + } + + if ($items.Count -gt 0) { + $rows = '' + foreach ($it in $items) { + $encName = ConvertTo-SPSHtmlEncoded -Value "$($it.Name)" + $encItemDetail = ConvertTo-SPSHtmlEncoded -Value "$($it.Detail)" + $exit = if ($null -ne $it.ExitCode -and "$($it.ExitCode)" -ne '') { ConvertTo-SPSHtmlEncoded -Value "$($it.ExitCode)" } else { '' } + $rows += "$encName$(& $badge $it.State)$encItemDetail$exit" + } + $sections += "
$rows
ItemStateDetailExit
" + } + $sections += '
' + } + $sections += '
' + } + + if ($scopes.Count -eq 0) { + $sections = '
No status recorded yet for this campaign. Waiting for the first update...
' + } + + # ---- Metadata ----------------------------------------------------------------- + $encTitle = ConvertTo-SPSHtmlEncoded -Value $Title + $encEnv = ConvertTo-SPSHtmlEncoded -Value $EnvName + $encApp = ConvertTo-SPSHtmlEncoded -Value $AppCode + $encFarm = ConvertTo-SPSHtmlEncoded -Value $FarmName + $encVersion = ConvertTo-SPSHtmlEncoded -Value $Version + $generated = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' + + $metaParts = @() + if (-not [string]::IsNullOrEmpty($encApp)) { $metaParts += "AppCode: $encApp" } + if (-not [string]::IsNullOrEmpty($encEnv)) { $metaParts += "Environment: $encEnv" } + if (-not [string]::IsNullOrEmpty($encFarm)) { $metaParts += "Farm: $encFarm" } + $metaParts += "Generated: $generated" + $metaParts += "SPSUpdate $encVersion" + $metaLine = $metaParts -join ' · ' + + $effectiveRefresh = if ($Completed) { 0 } else { $RefreshSeconds } + $liveNote = if ($effectiveRefresh -gt 0) { + "● live · auto-refresh every ${effectiveRefresh}s" + } + else { 'Campaign completed (auto-refresh off).' } + + $footerNote = if ($effectiveRefresh -gt 0) { + 'It is assembled from the shared status store and refreshes itself while patching is in progress.' + } + else { + 'It is assembled from the shared status store and reflects the completed campaign.' + } + + # ---- Assemble ----------------------------------------------------------------- + $html = (Get-SPSReportHtmlHead -Title $encTitle -RefreshSeconds $effectiveRefresh) + + "

$encTitle

" + + "
$metaLine · $liveNote
" + + "

Overall

$cards
" + + $sections + + "
Generated by SPSUpdate $encVersion. $footerNote
" + + '' + + $outDir = Split-Path -Path $OutputFile -Parent + if (-not [string]::IsNullOrEmpty($outDir) -and -not (Test-Path -Path $outDir)) { + $null = New-Item -Path $outDir -ItemType Directory -Force + } + # Atomic write so an open browser never reads a half-written dashboard. + $tmpPath = '{0}.tmp.{1}' -f $OutputFile, ([guid]::NewGuid().ToString('N')) + $encoding = New-Object System.Text.UTF8Encoding($true) + try { + [System.IO.File]::WriteAllText($tmpPath, $html, $encoding) + Move-Item -Path $tmpPath -Destination $OutputFile -Force -ErrorAction Stop + } + catch { + if (Test-Path -Path $tmpPath) { Remove-Item -Path $tmpPath -Force -ErrorAction SilentlyContinue } + Set-Content -Path $OutputFile -Value $html -Force -Encoding UTF8 + } + + return $OutputFile +} diff --git a/src/Modules/SPSUpdate.Common/Public/Get-SPSStatusCampaignPath.ps1 b/src/Modules/SPSUpdate.Common/Public/Get-SPSStatusCampaignPath.ps1 new file mode 100644 index 0000000..2d48817 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Get-SPSStatusCampaignPath.ps1 @@ -0,0 +1,81 @@ +function Get-SPSStatusCampaignPath { + <# + .SYNOPSIS + Resolves the campaign folder of the patching status store. + + .DESCRIPTION + Builds the folder that holds the status scope files for one patching campaign: + \--. The root is the configured + StatusStorePath (a UNC share shared by every server) when provided, otherwise it + falls back to a local 'status' folder under the Results folder (in which case + ProductUpdate runs launched on other servers are not captured centrally). + + The folder is created when -CreateIfMissing is specified. + + .PARAMETER StatusStorePath + The configured StatusStorePath (UNC share). Empty to use the local fallback. + + .PARAMETER ResultsFolder + The local Results folder used for the fallback when StatusStorePath is empty. + + .PARAMETER Application + Application code (from the config). + + .PARAMETER Environment + Environment/configuration name (from the config). + + .PARAMETER FarmName + Farm name (from the config). + + .PARAMETER CreateIfMissing + Create the campaign folder if it does not exist. + + .EXAMPLE + Get-SPSStatusCampaignPath -StatusStorePath $cfg.StatusStorePath -ResultsFolder $results -Application 'contoso' -Environment 'PROD' -FarmName 'CONTENT' -CreateIfMissing + #> + [CmdletBinding()] + [OutputType([System.String])] + param + ( + [Parameter()] + [AllowEmptyString()] + [System.String] + $StatusStorePath, + + [Parameter(Mandatory = $true)] + [System.String] + $ResultsFolder, + + [Parameter(Mandatory = $true)] + [System.String] + $Application, + + [Parameter(Mandatory = $true)] + [System.String] + $Environment, + + [Parameter(Mandatory = $true)] + [System.String] + $FarmName, + + [Parameter()] + [switch] + $CreateIfMissing + ) + + if ([string]::IsNullOrWhiteSpace($StatusStorePath)) { + $root = Join-Path -Path $ResultsFolder -ChildPath 'status' + } + else { + $root = $StatusStorePath + } + + $campaignName = ('{0}-{1}-{2}' -f $Application, $Environment, $FarmName) -replace '[^A-Za-z0-9_.-]', '_' + $campaignPath = Join-Path -Path $root -ChildPath $campaignName + + if ($CreateIfMissing -and -not (Test-Path -Path $campaignPath)) { + $null = New-Item -Path $campaignPath -ItemType Directory -Force + } + + return $campaignPath +} diff --git a/src/Modules/SPSUpdate.Common/Public/Get-SPSUpdateStatus.ps1 b/src/Modules/SPSUpdate.Common/Public/Get-SPSUpdateStatus.ps1 new file mode 100644 index 0000000..54f42b0 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Get-SPSUpdateStatus.ps1 @@ -0,0 +1,55 @@ +function Get-SPSUpdateStatus { + <# + .SYNOPSIS + Reads and merges every patching progress record of a campaign. + + .DESCRIPTION + Get-SPSUpdateStatus reads all scope files (`*__*.json`) written by + Set-SPSUpdateStatus in the campaign folder and returns them as an array of + scope objects (Server, Scope, Phase, State, Detail, Percent, StartedAt, + UpdatedAt, Items). It is the data source for Export-SPSUpdateProgressReport. + + Files that are missing, empty or momentarily unreadable (being written) are + skipped silently so a partially captured campaign never throws. Temporary + files (`*.tmp.*`) are ignored. + + .PARAMETER CampaignPath + Folder of the patching campaign to read (for example + \--). + + .EXAMPLE + Get-SPSUpdateStatus -CampaignPath $c | Sort-Object Server, Scope + #> + [CmdletBinding()] + [OutputType([System.Object[]])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $CampaignPath + ) + + if (-not (Test-Path -Path $CampaignPath)) { + return @() + } + + $files = Get-ChildItem -Path $CampaignPath -Filter '*.json' -File -ErrorAction SilentlyContinue | + Where-Object { $_.Name -notlike '*.tmp.*' } + + $records = New-Object System.Collections.Generic.List[object] + foreach ($file in $files) { + try { + $raw = Get-Content -Path $file.FullName -Raw -ErrorAction Stop + if ([string]::IsNullOrWhiteSpace($raw)) { continue } + $record = $raw | ConvertFrom-Json -ErrorAction Stop + if ($null -ne $record) { + $records.Add($record) + } + } + catch { + Write-Verbose -Message "Get-SPSUpdateStatus: skipping unreadable '$($file.FullName)': $($_.Exception.Message)" + } + } + + return $records.ToArray() +} diff --git a/src/Modules/SPSUpdate.Common/Public/Set-SPSUpdateStatus.ps1 b/src/Modules/SPSUpdate.Common/Public/Set-SPSUpdateStatus.ps1 new file mode 100644 index 0000000..13ed0cf --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/Set-SPSUpdateStatus.ps1 @@ -0,0 +1,210 @@ +function Set-SPSUpdateStatus { + <# + .SYNOPSIS + Writes (upserts) a patching progress record into the shared status store. + + .DESCRIPTION + Set-SPSUpdateStatus persists one "scope" of patching progress as a JSON file + in the campaign folder of the status store (a UNC share shared by every farm + server, or a local folder as a fallback). It is the building block of the + near-real-time SPSUpdate dashboard. + + A scope is identified by Server + Scope (e.g. APP1 + 'Sequence1'). Each writer + owns a distinct scope file, so the four parallel sequence tasks, the local + ProductUpdate on each server and the master (which records the wizard state of + every server) never write the same file concurrently. Writes are atomic + (written to a temporary file then moved into place) so a reader never sees a + half-written document, with a short retry to absorb transient UNC sharing + violations. + + Optionally upserts a single item inside the scope (e.g. a database name or a + setup file) with its own state / detail / exit code, so the dashboard can show + per-database or per-binary progress. + + .PARAMETER CampaignPath + Folder of the current patching campaign (for example + \--). Created if missing. + + .PARAMETER Scope + Scope key that identifies the owning writer and names the file + (__.json). Examples: 'ProductUpdate', 'Sequence1', 'Wizard'. + + .PARAMETER Phase + Logical phase the scope belongs to, used to group the dashboard. + + .PARAMETER Server + Server the scope relates to. Defaults to the local computer name. + + .PARAMETER State + Scope-level state. + + .PARAMETER Detail + Free-form scope-level detail. + + .PARAMETER Percent + Optional scope-level completion percentage (0-100). + + .PARAMETER Item + Optional item name to upsert inside the scope (e.g. a database or a setup file). + + .PARAMETER ItemState + State of the upserted item. + + .PARAMETER ItemDetail + Detail of the upserted item. + + .PARAMETER ExitCode + Optional exit code recorded on the item. + + .EXAMPLE + Set-SPSUpdateStatus -CampaignPath $c -Scope 'Sequence1' -Phase 'Upgrade' -State 'Running' -Item 'DB_A' -ItemState 'Done' -ItemDetail 'upgraded' -ExitCode 0 + #> + [CmdletBinding(SupportsShouldProcess = $true)] + [OutputType([System.String])] + param + ( + [Parameter(Mandatory = $true)] + [System.String] + $CampaignPath, + + [Parameter(Mandatory = $true)] + [System.String] + $Scope, + + [Parameter()] + [ValidateSet('ProductUpdate', 'Mount', 'Upgrade', 'Sequence', 'Wizard', 'SideBySide')] + [System.String] + $Phase = 'Sequence', + + [Parameter()] + [System.String] + $Server = $env:COMPUTERNAME, + + [Parameter()] + [ValidateSet('Pending', 'Running', 'Done', 'Failed', 'Warning', 'Skipped')] + [System.String] + $State, + + [Parameter()] + [System.String] + $Detail, + + [Parameter()] + [System.Nullable[int]] + $Percent, + + [Parameter()] + [System.String] + $Item, + + [Parameter()] + [ValidateSet('Pending', 'Running', 'Done', 'Failed', 'Warning', 'Skipped')] + [System.String] + $ItemState, + + [Parameter()] + [System.String] + $ItemDetail, + + [Parameter()] + [System.Nullable[int]] + $ExitCode + ) + + if (-not (Test-Path -Path $CampaignPath)) { + $null = New-Item -Path $CampaignPath -ItemType Directory -Force + } + + $safeServer = ($Server -replace '[^A-Za-z0-9_.-]', '_') + $safeScope = ($Scope -replace '[^A-Za-z0-9_.-]', '_') + $fileName = '{0}__{1}.json' -f $safeServer, $safeScope + $filePath = Join-Path -Path $CampaignPath -ChildPath $fileName + $now = (Get-Date).ToString('o') + + # Load the existing scope record (if any) so items accumulate across calls. + $record = $null + if (Test-Path -Path $filePath) { + try { + $raw = Get-Content -Path $filePath -Raw -ErrorAction Stop + if (-not [string]::IsNullOrWhiteSpace($raw)) { + $record = $raw | ConvertFrom-Json -ErrorAction Stop + } + } + catch { + Write-Verbose -Message "Set-SPSUpdateStatus: could not read existing '$filePath', starting fresh: $($_.Exception.Message)" + } + } + + if ($null -eq $record) { + $record = [PSCustomObject]@{ + Server = $Server + Scope = $Scope + Phase = $Phase + State = 'Pending' + Detail = '' + Percent = $null + StartedAt = $now + UpdatedAt = $now + Items = @() + } + } + + $record.Server = $Server + $record.Scope = $Scope + $record.Phase = $Phase + $record.UpdatedAt = $now + if ($PSBoundParameters.ContainsKey('State')) { $record.State = $State } + if ($PSBoundParameters.ContainsKey('Detail')) { $record.Detail = $Detail } + if ($PSBoundParameters.ContainsKey('Percent')) { $record.Percent = $Percent } + + # Upsert the optional item. + if ($PSBoundParameters.ContainsKey('Item') -and -not [string]::IsNullOrEmpty($Item)) { + $items = @($record.Items) + $existing = $items | Where-Object { $_.Name -eq $Item } | Select-Object -First 1 + if ($null -eq $existing) { + $existing = [PSCustomObject]@{ + Name = $Item + State = 'Pending' + Detail = '' + ExitCode = $null + UpdatedAt = $now + } + $items += $existing + } + $existing.UpdatedAt = $now + if ($PSBoundParameters.ContainsKey('ItemState')) { $existing.State = $ItemState } + if ($PSBoundParameters.ContainsKey('ItemDetail')) { $existing.Detail = $ItemDetail } + if ($PSBoundParameters.ContainsKey('ExitCode')) { $existing.ExitCode = $ExitCode } + $record.Items = $items + } + + if (-not $PSCmdlet.ShouldProcess($filePath, 'Write SPSUpdate status')) { + return $filePath + } + + $json = $record | ConvertTo-Json -Depth 6 + $tmpPath = '{0}.tmp.{1}' -f $filePath, ([guid]::NewGuid().ToString('N')) + $encoding = New-Object System.Text.UTF8Encoding($false) + + $attempts = 0 + $written = $false + while (-not $written -and $attempts -lt 5) { + $attempts++ + try { + [System.IO.File]::WriteAllText($tmpPath, $json, $encoding) + Move-Item -Path $tmpPath -Destination $filePath -Force -ErrorAction Stop + $written = $true + } + catch { + if (Test-Path -Path $tmpPath) { Remove-Item -Path $tmpPath -Force -ErrorAction SilentlyContinue } + if ($attempts -ge 5) { + Write-Warning -Message "Set-SPSUpdateStatus: failed to write '$filePath' after $attempts attempts: $($_.Exception.Message)" + } + else { + Start-Sleep -Milliseconds (150 * $attempts) + } + } + } + + return $filePath +} diff --git a/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 b/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 index fb08b19..9c926c1 100644 --- a/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 +++ b/src/Modules/SPSUpdate.Common/Public/Start-SPSProductUpdate.ps1 @@ -1,5 +1,6 @@ function Start-SPSProductUpdate { [CmdletBinding(SupportsShouldProcess = $true, ConfirmImpact = 'High')] + [OutputType([System.Nullable[int]])] param ( [Parameter(Mandatory = $true)] @@ -136,12 +137,14 @@ Error codes can be found at https://aka.ms/installerrorcodes Start-Process -FilePath "iisreset.exe" ` -ArgumentList "-start" ` -Wait ` - -PassThru + -PassThru | Out-Null write-Verbose -Message "All services started. Installation process complete." } + return [int]$setupInstall.ExitCode } else { # Version of SharePoint is equal or greater than the patch version. Patch is installed. Write-Verbose -Message "The version of SharePoint installed is equal or higher than the update. No action needed." + return $null } } diff --git a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 index 53f2a15..b2fb943 100644 --- a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 +++ b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 @@ -1,6 +1,6 @@ @{ RootModule = 'SPSUpdate.Common.psm1' - ModuleVersion = '4.1.0' + ModuleVersion = '4.2.0' GUID = 'd6f4e2b7-3a1c-4d8e-9f2a-6c5b7e0a1d34' Author = 'Jean-Cyril DROUHIN' CompanyName = 'luigilink' @@ -14,14 +14,18 @@ 'Add-SPSUpdateEvent' 'Copy-SPSSideBySideFilesRemote' 'Export-SPSUpdateDbReport' + 'Export-SPSUpdateProgressReport' 'Get-SPSInstalledProductVersion' 'Get-SPSSecret' 'Get-SPSServersPatchStatus' + 'Get-SPSStatusCampaignPath' + 'Get-SPSUpdateStatus' 'Initialize-SPSContentDbJsonFile' 'Mount-SPSContentDatabase' 'Remove-SPSScheduledTask' 'Set-SPSSecret' 'Set-SPSSideBySideToken' + 'Set-SPSUpdateStatus' 'Start-SPSConfigExe' 'Start-SPSConfigExeRemote' 'Start-SPSProductUpdate' diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index b8b038c..6378035 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -63,7 +63,7 @@ param $ConfigFile, # Path to the configuration file [Parameter(Position = 1)] - [validateSet('Install', 'Uninstall', 'Default', 'ProductUpdate', 'InitContentDB', IgnoreCase = $true)] + [validateSet('Install', 'Uninstall', 'Default', 'ProductUpdate', 'InitContentDB', 'ResetStatus', IgnoreCase = $true)] [System.String] $Action = 'Default', @@ -172,6 +172,11 @@ function Get-SPSUpdateConfiguration { $config.SideBySideToken.BuildVersion = '' } + # StatusStorePath is optional; empty string means "use the local Results\status folder". + if (-not $config.ContainsKey('StatusStorePath') -or $null -eq $config.StatusStorePath) { + $config.StatusStorePath = '' + } + return $config } @@ -203,6 +208,74 @@ $fullScriptPath = Join-Path -Path $PSScriptRoot -ChildPath 'SPSUpdate.ps1' $spsUpdateDBsPath = Join-Path -Path $pathConfigFolder -ChildPath $spsUpdateDBsFile $spsUpdateDbReportPath = Join-Path -Path $pathResultsFolder -ChildPath $spsUpdateDbReportFile +# Resolve the patching status store campaign folder (UNC share when configured, local +# Results\status fallback otherwise). Shared by every server/task to feed the live +# dashboard. Identity is -- so same-campaign actions land together. +$thisServer = $env:COMPUTERNAME +$statusCampaignPath = $null +try { + $statusCampaignPath = Get-SPSStatusCampaignPath -StatusStorePath $envCfg.StatusStorePath ` + -ResultsFolder $pathResultsFolder ` + -Application $Application ` + -Environment $Environment ` + -FarmName $spFarmName +} +catch { + Write-Warning -Message "Could not resolve the status store campaign path: $($_.Exception.Message)" +} +$statusDashboardPath = if ($null -ne $statusCampaignPath) { Join-Path -Path $statusCampaignPath -ChildPath '_dashboard.html' } else { $null } + +# Local helper: best-effort status write. Never blocks the run on a status failure. +function Write-SPSStatus { + param( + [Parameter(Mandatory = $true)][System.String] $Scope, + [Parameter(Mandatory = $true)][ValidateSet('ProductUpdate', 'Mount', 'Upgrade', 'Sequence', 'Wizard', 'SideBySide')][System.String] $Phase, + [System.String] $Server = $thisServer, + [System.String] $State, + [System.String] $Detail, + [System.Nullable[int]] $Percent, + [System.String] $Item, + [System.String] $ItemState, + [System.String] $ItemDetail, + [System.Nullable[int]] $ExitCode + ) + if ([string]::IsNullOrEmpty($statusCampaignPath)) { return } + try { + $params = @{ CampaignPath = $statusCampaignPath; Scope = $Scope; Phase = $Phase; Server = $Server; Confirm = $false } + if ($PSBoundParameters.ContainsKey('State')) { $params.State = $State } + if ($PSBoundParameters.ContainsKey('Detail')) { $params.Detail = $Detail } + if ($PSBoundParameters.ContainsKey('Percent') -and $null -ne $Percent) { $params.Percent = $Percent } + if ($PSBoundParameters.ContainsKey('Item')) { $params.Item = $Item } + if ($PSBoundParameters.ContainsKey('ItemState')) { $params.ItemState = $ItemState } + if ($PSBoundParameters.ContainsKey('ItemDetail')) { $params.ItemDetail = $ItemDetail } + if ($PSBoundParameters.ContainsKey('ExitCode') -and $null -ne $ExitCode) { $params.ExitCode = $ExitCode } + $null = Set-SPSUpdateStatus @params + } + catch { + Write-Warning -Message "Failed to write patching status ($Scope): $($_.Exception.Message)" + } +} + +# Local helper: (re)generate the live dashboard from the status store. +function Write-SPSDashboard { + param([switch] $Completed) + if ([string]::IsNullOrEmpty($statusCampaignPath)) { return } + try { + $params = @{ + CampaignPath = $statusCampaignPath + OutputFile = $statusDashboardPath + EnvName = $Environment + AppCode = $Application + FarmName = $spFarmName + } + if ($Completed) { $params.Completed = $true } + $null = Export-SPSUpdateProgressReport @params + } + catch { + Write-Warning -Message "Failed to generate patching dashboard: $($_.Exception.Message)" + } +} + # Local helper: (re)generate the ContentDatabase inventory HTML report from the JSON # inventory, into the Results folder. Never blocks the run on a report failure. function Write-SPSUpdateDbReport { @@ -333,6 +406,41 @@ Exception: $_ # 3. Execute Action parameter switch ($Action) { + 'ResetStatus' { + # Clear the status store campaign folder so a fresh patching round starts clean, + # then create the folder and an empty "waiting" dashboard so it can be opened in a + # browser before the ProductUpdate runs and the master Default run begin. + try { + if ([string]::IsNullOrEmpty($statusCampaignPath)) { + Write-Warning -Message 'No status store campaign path resolved; nothing to reset.' + } + else { + if (Test-Path -Path $statusCampaignPath) { + Write-Output "Resetting patching status store campaign: $statusCampaignPath" + Get-ChildItem -Path $statusCampaignPath -File -ErrorAction SilentlyContinue | + Remove-Item -Force -ErrorAction SilentlyContinue + Write-Output 'Status store campaign cleared.' + } + else { + Write-Output "Creating patching status store campaign: $statusCampaignPath" + New-Item -Path $statusCampaignPath -ItemType Directory -Force | Out-Null + } + # Generate the empty dashboard now so it is ready to open before patching. + Write-SPSDashboard + if (-not [string]::IsNullOrEmpty($statusDashboardPath)) { + Write-Output "Live dashboard ready (open it in a browser): $statusDashboardPath" + } + } + } + catch { + $catchMessage = @" +Failed to reset the status store campaign: $($statusCampaignPath) +Exception: $_ +"@ + Write-Error -Message $catchMessage + Add-SPSUpdateEvent -Message $catchMessage -Source 'Set-SPSUpdateStatus' -EntryType 'Error' + } + } '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 @@ -526,6 +634,8 @@ Exception: $_ } 'ProductUpdate' { # Run ProductUpdate + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -State 'Running' -Detail "Installing $(@($envCfg.Binaries.SetupFileName).Count) update(s)" + Write-SPSDashboard try { foreach ($setupFile in $envCfg.Binaries.SetupFileName) { $fullSetupFilePath = Join-Path -Path $envCfg.Binaries.SetupFullPath -ChildPath $setupFile @@ -536,14 +646,32 @@ SharePoint Server: $($spTargetServer) Setup File Path: $($fullSetupFilePath) Shutdown Services: $($envCfg.Binaries.ShutdownServices) "@ + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -State 'Running' -Item $setupFile -ItemState 'Running' -ItemDetail 'installing' + Write-SPSDashboard # 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 $envCfg.Binaries.ShutdownServices -Verbose + $puExitCode = Start-SPSProductUpdate -SetupFile $fullSetupFilePath -ShutdownServices $envCfg.Binaries.ShutdownServices -Verbose + if ($null -eq $puExitCode) { + # No install happened: already at or above the patch level. + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Item $setupFile -ItemState 'Done' -ItemDetail 'already installed' + } + else { + $puDetail = switch ([int]$puExitCode) { + 0 { 'installed' } + 17022 { 'installed - reboot required' } + 17025 { 'already installed' } + default { "installed (exit $puExitCode)" } + } + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Item $setupFile -ItemState 'Done' -ItemDetail $puDetail -ExitCode ([int]$puExitCode) + } + Write-SPSDashboard } + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -State 'Done' -Detail 'All updates processed' + Write-SPSDashboard } catch { # Handle errors during Run ProductUpdate @@ -554,6 +682,8 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSProductUpdate' -EntryType 'Error' + Write-SPSStatus -Scope 'ProductUpdate' -Phase 'ProductUpdate' -State 'Failed' -Detail "$($_.Exception.Message)" + Write-SPSDashboard if ($script:TranscriptStarted) { Stop-Transcript | Out-Null $script:TranscriptStarted = $false @@ -563,6 +693,8 @@ Exception: $_ } Default { if ($PSBoundParameters.ContainsKey('Sequence')) { + $seqScope = "Sequence$Sequence" + $seqPhase = if ($envCfg.MountContentDatabase) { 'Mount' } else { 'Upgrade' } try { Write-Output "Update Script in progress | Sequence $Sequence - Please Wait ..." switch ($Sequence) { @@ -571,7 +703,11 @@ Exception: $_ 3 { $dbs = $jsonDbCfg.SPContentDatabase3 } 4 { $dbs = $jsonDbCfg.SPContentDatabase4 } } - foreach ($db in $dbs) { + $dbList = @($dbs) + $dbTotal = $dbList.Count + $dbDone = 0 + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -State 'Running' -Percent 0 -Detail "$dbTotal database(s)" + foreach ($db in $dbList) { # 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 @@ -579,11 +715,14 @@ Exception: $_ # 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). + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -Item "$($db.Name)" -ItemState 'Running' -ItemDetail 'processing' + $dbFailed = $false if ($envCfg.MountContentDatabase) { try { Mount-SPSContentDatabase -Name $db.Name -WebAppUrl $db.WebAppUrl -DatabaseServer $db.Server } catch { + $dbFailed = $true $catchMessage = @" Failed to Mount SPContentDatabase '$($db.Name)' on WebApplication '$($db.WebAppUrl)' Target SPFarm: $($spFarmName) @@ -591,12 +730,19 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Mount-SPSContentDatabase' -EntryType 'Error' + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -Item "$($db.Name)" -ItemState 'Failed' -ItemDetail "Mount failed: $($_.Exception.Message)" } } - if ($envCfg.UpgradeContentDatabase) { + if (-not $dbFailed -and $envCfg.UpgradeContentDatabase) { Update-SPSContentDatabase -Name $db.Name } + if (-not $dbFailed) { + $dbDone++ + $pct = if ($dbTotal -gt 0) { [int]([math]::Round($dbDone / $dbTotal * 100, 0)) } else { 100 } + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -State 'Running' -Percent $pct -Item "$($db.Name)" -ItemState 'Done' -ItemDetail 'processed' + } } + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -State 'Done' -Percent 100 -Detail "$dbDone/$dbTotal processed" } catch { # Handle errors during Update Script Sequence @@ -607,6 +753,7 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Update-SPSContentDatabase' -EntryType 'Error' + Write-SPSStatus -Scope $seqScope -Phase $seqPhase -State 'Failed' -Detail "$($_.Exception.Message)" } } else { @@ -684,8 +831,21 @@ Exception: $_ Write-Output "Running Scheduled Task $taskName in $script:TaskPath Task Path" $startResult = Start-SPSScheduledTask -Name $taskName -TaskPath $script:TaskPath -ErrorAction Stop Write-Output "Start requested for $($startResult.Name) in $($startResult.TaskPath). Current state: $($startResult.State)" - Write-Output 'Avoid conflicts with OWSTimer process - Pause between 60 to 90 seconds' - Start-Sleep -Seconds (get-random (60..90)) + # Refresh the dashboard right after each start so a sequence that + # already began writing its status shows up immediately. + Write-SPSDashboard + # Pause 60-90s between starts to avoid OWSTimer conflicts, but keep + # the dashboard live by regenerating it every ~10s during the wait + # (the started sequences write their per-database progress meanwhile). + $pauseSeconds = (Get-Random -Minimum 60 -Maximum 91) + Write-Output "Avoid conflicts with OWSTimer process - Pause $pauseSeconds seconds" + $waited = 0 + while ($waited -lt $pauseSeconds) { + $chunk = [System.Math]::Min(10, ($pauseSeconds - $waited)) + Start-Sleep -Seconds $chunk + $waited += $chunk + Write-SPSDashboard + } } catch { # Handle errors during Start scheduled Task for Upgrade SPContentDatabase in Parallel @@ -726,6 +886,9 @@ Exception: $_ $allTasksFinished = $false } } + # Refresh the live dashboard from the shared status store while the + # parallel sequence tasks write their progress into it. + Write-SPSDashboard if (-not $allTasksFinished) { Write-Output 'At least one task is still running. Waiting...' Start-Sleep -Seconds 10 @@ -740,11 +903,18 @@ Exception: $_ Write-Output "Getting Patch Status on server: $($env:COMPUTERNAME)" if ((Get-SPSServersPatchStatus -Server "$($env:COMPUTERNAME)") -eq 'NoActionRequired') { Write-Output "No Action Required on server: $($env:COMPUTERNAME). Skipping Configuration Wizard." + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Skipped' -Detail 'No action required' } else { Write-Output "Action Required on server: $($env:COMPUTERNAME). Proceeding to run Configuration Wizard." - Start-SPSConfigExe + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Running' -Detail 'Running PSConfig' + Write-SPSDashboard + $wizResult = Start-SPSConfigExe + $wizExit = @($wizResult) | Where-Object { $_ -is [int] } | Select-Object -Last 1 + $wizDetail = if ($null -ne $wizExit) { "PSConfig completed (exit $([int]$wizExit))" } else { 'PSConfig completed' } + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Done' -Detail $wizDetail } + Write-SPSDashboard } catch { # Handle errors during Run SPConfigWizard on Master SharePoint Server @@ -756,6 +926,8 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSConfigExe' -EntryType 'Error' + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server $thisServer -State 'Failed' -Detail "$($_.Exception.Message)" + Write-SPSDashboard } # Run SPConfigWizard on other SharePoint Server @@ -766,12 +938,19 @@ Exception: $_ Write-Output "Getting Patch Status on server: $($spServer.Name)" if ((Get-SPSServersPatchStatus -Server "$($spServer.Name)") -eq 'NoActionRequired') { Write-Output "No Action Required on server: $($spServer.Name). Skipping Configuration Wizard." + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Skipped' -Detail 'No action required' } else { Write-Output "Action Required on server: $($spServer.Name). Proceeding to run Configuration Wizard." $spTargetServer = "$($spServer.Name).$($scriptFQDN)" - Start-SPSConfigExeRemote -Server $spTargetServer -InstallAccount $credential + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Running' -Detail 'Running PSConfig (remote)' + Write-SPSDashboard + $wizResultRemote = Start-SPSConfigExeRemote -Server $spTargetServer -InstallAccount $credential + $wizExitRemote = @($wizResultRemote) | Where-Object { $_ -is [int] } | Select-Object -Last 1 + $wizDetailRemote = if ($null -ne $wizExitRemote) { "PSConfig completed (exit $([int]$wizExitRemote))" } else { 'PSConfig completed' } + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Done' -Detail $wizDetailRemote } + Write-SPSDashboard } catch { # Handle errors during Run SPConfigWizard on remote SharePoint Server @@ -783,6 +962,8 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Start-SPSConfigExeRemote' -EntryType 'Error' + Write-SPSStatus -Scope 'Wizard' -Phase 'Wizard' -Server "$($spServer.Name)" -State 'Failed' -Detail "$($_.Exception.Message)" + Write-SPSDashboard } } @@ -790,7 +971,11 @@ Exception: $_ if (-not([string]::IsNullOrEmpty($envCfg.SideBySideToken.BuildVersion))) { try { Write-Output "Configuring SharePoint SideBySideToken on farm $($spFarmName)" + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server $thisServer -State 'Running' -Detail "Token $($envCfg.SideBySideToken.BuildVersion)" + Write-SPSDashboard Set-SPSSideBySideToken -BuildVersion "$($envCfg.SideBySideToken.BuildVersion)" -EnableSideBySide $envCfg.SideBySideToken.Enable + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server $thisServer -State 'Done' -Detail 'Token configured' + Write-SPSDashboard } catch { # Handle errors during Run Set-SPSSideBySideToken @@ -801,6 +986,8 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Set-SPSSideBySideToken' -EntryType 'Error' + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server $thisServer -State 'Failed' -Detail "$($_.Exception.Message)" + Write-SPSDashboard } } @@ -811,6 +998,7 @@ Exception: $_ try { $spTargetServer = "$($spServer.Name).$($scriptFQDN)" Copy-SPSSideBySideFilesRemote -Server $spTargetServer -InstallAccount $credential + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server "$($spServer.Name)" -State 'Done' -Detail 'Side-by-side files copied' } catch { # Handle errors during Run Copy-SPSSideBySideFilesAllServers @@ -822,9 +1010,13 @@ Exception: $_ "@ Write-Error -Message $catchMessage Add-SPSUpdateEvent -Message $catchMessage -Source 'Copy-SPSSideBySideFiles' -EntryType 'Error' + Write-SPSStatus -Scope 'SideBySide' -Phase 'SideBySide' -Server "$($spServer.Name)" -State 'Failed' -Detail "$($_.Exception.Message)" } } } + + # Final dashboard render: mark the campaign completed (auto-refresh off). + Write-SPSDashboard -Completed } } } diff --git a/src/SPSUpdate_README.md b/src/SPSUpdate_README.md index ddeb96d..4b19b62 100644 --- a/src/SPSUpdate_README.md +++ b/src/SPSUpdate_README.md @@ -161,6 +161,33 @@ and configures the side-by-side token when enabled: E:\SCRIPT\SPSUpdate.ps1 -ConfigFile 'E:\SCRIPT\Config\CONTOSO-PROD-CONTENT.psd1' ``` +## 📡 Near real-time dashboard (optional) + +Set `StatusStorePath` in the config to a UNC share writable by the InstallAccount from +every server to get a live HTML dashboard of the patching campaign: + +1. `SPSUpdate.ps1 -ConfigFile '.psd1' -Action ResetStatus` (clears the campaign). +2. Open `\--\_dashboard.html` in a browser (auto-refresh). +3. Run `-Action ProductUpdate` on each server, then the default master run. + +If `StatusStorePath` is empty, the dashboard falls back to the local `Results\status` +folder (ProductUpdate on other servers is then not captured centrally). + +> IMPORTANT: grant the InstallAccount (the scheduled-task service account) **Modify** on the +> share (SMB share + NTFS). The upgrade/mount sequence tasks run as that account; without +> write access the upgrade phase will not appear on the dashboard. Run +> `Test-SPSUpdateReadiness.ps1` to verify it (it probes write access as both your account and +> the InstallAccount). + +## ✅ Pre-flight readiness check (optional) + +```powershell +E:\SCRIPT\Test-SPSUpdateReadiness.ps1 -ConfigFile 'E:\SCRIPT\Config\CONTOSO-PROD-CONTENT.psd1' +``` + +Read-only validation of the module, config, DPAPI secret, elevation, status store write +access and CredSSP reachability. Exits non-zero on any failure. + ## 🔄 Uninstalling To remove the scheduled tasks and the stored secret: diff --git a/src/Test-SPSUpdateReadiness.ps1 b/src/Test-SPSUpdateReadiness.ps1 new file mode 100644 index 0000000..7df2a99 --- /dev/null +++ b/src/Test-SPSUpdateReadiness.ps1 @@ -0,0 +1,313 @@ +<# + .SYNOPSIS + Pre-flight readiness check for SPSUpdate. + + .DESCRIPTION + Validates, before running SPSUpdate.ps1, that the environment is ready: + the SPSUpdate.Common module imports, the environment configuration parses + and exposes the required keys, the service credential exists in secrets.psd1 + and decrypts under the current account (DPAPI), the session is elevated, the + status store (UNC share, v4.2.0+) is writable, and each farm server is + reachable for CredSSP remoting. + + Read-only: it never changes configuration, credentials or the farm. The only + side effect is a temporary probe file written and immediately deleted in the + status store to confirm it is writable. + + .PARAMETER ConfigFile + Path to the environment configuration .psd1 file (same one passed to + SPSUpdate.ps1). secrets.psd1 is looked up in the same folder. + + .PARAMETER SkipNetwork + Skip the per-server WinRM/CredSSP reachability probe (useful off-server). + + .PARAMETER SkipSharePoint + Skip enumerating the farm servers via Get-SPServer. + + .EXAMPLE + .\Test-SPSUpdateReadiness.ps1 -ConfigFile 'Config\CONTOSO-PROD-CONTENT.psd1' + + .NOTES + FileName: Test-SPSUpdateReadiness.ps1 + Author: luigilink (Jean-Cyril DROUHIN) + Project: https://github.com/luigilink/SPSUpdate +#> + +#Requires -Version 5.1 + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSAvoidUsingWriteHost', '', + Justification = 'This is an interactive, operator-facing readiness tool whose purpose is colored console output.')] +[CmdletBinding()] +param +( + [Parameter(Mandatory = $true)] + [System.String] + $ConfigFile, + + [Parameter()] + [switch] + $SkipNetwork, + + [Parameter()] + [switch] + $SkipSharePoint, + + [Parameter()] + [System.Int32] + $TimeoutSeconds = 5 +) + +$script:results = New-Object System.Collections.Generic.List[object] + +function Add-CheckResult { + param + ( + [Parameter(Mandatory = $true)] [System.String] $Section, + [Parameter(Mandatory = $true)] [System.String] $Name, + [Parameter(Mandatory = $true)] [ValidateSet('PASS', 'FAIL', 'WARN', 'SKIP')] [System.String] $Status, + [Parameter()] [System.String] $Detail = '' + ) + + $script:results.Add([PSCustomObject]@{ Section = $Section; Name = $Name; Status = $Status; Detail = $Detail }) + + switch ($Status) { + 'PASS' { $color = 'Green'; $glyph = '[ OK ]' } + 'FAIL' { $color = 'Red'; $glyph = '[FAIL]' } + 'WARN' { $color = 'Yellow'; $glyph = '[WARN]' } + 'SKIP' { $color = 'DarkGray'; $glyph = '[SKIP]' } + } + $line = '{0} {1}' -f $glyph, $Name + if (-not [string]::IsNullOrEmpty($Detail)) { $line += " - $Detail" } + Write-Host $line -ForegroundColor $color +} + +function Write-Section { + param ([System.String] $Title) + Write-Host '' + Write-Host "== $Title ==" -ForegroundColor Cyan +} + +Write-Host '' +Write-Host '============================================================' -ForegroundColor Cyan +Write-Host ' SPSUpdate - Readiness Check' -ForegroundColor Cyan +Write-Host " Computer : $env:COMPUTERNAME" -ForegroundColor Cyan +Write-Host " Date : $(Get-Date -Format 'yyyy-MM-dd HH:mm')" -ForegroundColor Cyan +Write-Host '============================================================' -ForegroundColor Cyan + +# 1. Module +Write-Section -Title 'Module' +$modulePath = Join-Path -Path $PSScriptRoot -ChildPath 'Modules\SPSUpdate.Common\SPSUpdate.Common.psd1' +if (Test-Path -Path $modulePath) { + try { + Import-Module -Name $modulePath -Force -ErrorAction Stop + $version = (Get-Module -Name SPSUpdate.Common).Version + Add-CheckResult -Section 'Module' -Name 'SPSUpdate.Common import' -Status 'PASS' -Detail "v$version" + } + catch { + Add-CheckResult -Section 'Module' -Name 'SPSUpdate.Common import' -Status 'FAIL' -Detail $_.Exception.Message + } +} +else { + Add-CheckResult -Section 'Module' -Name 'SPSUpdate.Common import' -Status 'FAIL' -Detail "Module not found at $modulePath" +} + +# 2. Configuration +Write-Section -Title 'Configuration' +$cfg = $null +if (-not (Test-Path -Path $ConfigFile)) { + Add-CheckResult -Section 'Config' -Name 'Configuration file' -Status 'FAIL' -Detail "Not found: $ConfigFile" +} +else { + try { + $cfg = Import-PowerShellDataFile -Path $ConfigFile -ErrorAction Stop + Add-CheckResult -Section 'Config' -Name 'Configuration file' -Status 'PASS' -Detail $ConfigFile + } + catch { + Add-CheckResult -Section 'Config' -Name 'Configuration file' -Status 'FAIL' -Detail "Parse error: $($_.Exception.Message)" + } +} + +if ($null -ne $cfg) { + foreach ($key in @('ConfigurationName', 'ApplicationName', 'Domain', 'FarmName', 'CredentialKey')) { + if ($cfg.Contains($key) -and -not [string]::IsNullOrWhiteSpace([string]$cfg[$key])) { + Add-CheckResult -Section 'Config' -Name "Key '$key'" -Status 'PASS' + } + else { + Add-CheckResult -Section 'Config' -Name "Key '$key'" -Status 'FAIL' -Detail 'Missing or empty' + } + } + + if ($cfg.Contains('Binaries') -and $cfg.Binaries) { + if ([string]::IsNullOrEmpty($cfg.Binaries.SetupFullPath)) { + Add-CheckResult -Section 'Config' -Name 'Binaries.SetupFullPath' -Status 'WARN' -Detail 'Empty (required when ProductUpdate is enabled)' + } + else { + Add-CheckResult -Section 'Config' -Name 'Binaries.SetupFullPath' -Status 'PASS' -Detail $cfg.Binaries.SetupFullPath + } + } + else { + Add-CheckResult -Section 'Config' -Name 'Binaries block' -Status 'WARN' -Detail 'Missing (ProductUpdate defaults will apply)' + } +} + +# 3. Secrets (DPAPI) +Write-Section -Title 'Secrets' +if ($null -ne $cfg -and $cfg.Contains('CredentialKey') -and $cfg.CredentialKey) { + $configFolder = Split-Path -Path $ConfigFile -Parent + if ([string]::IsNullOrEmpty($configFolder)) { $configFolder = '.' } + $secretsPath = Join-Path -Path $configFolder -ChildPath 'secrets.psd1' + if (-not (Test-Path -Path $secretsPath)) { + Add-CheckResult -Section 'Secrets' -Name 'secrets.psd1' -Status 'FAIL' -Detail "Not found at $secretsPath. Run SPSUpdate.ps1 -Action Install as the service account." + } + else { + if ($null -eq (Get-Module -Name SPSUpdate.Common)) { + Add-CheckResult -Section 'Secrets' -Name 'Get-SPSSecret' -Status 'SKIP' -Detail 'Module not loaded; cannot validate the secret' + } + else { + try { + $cred = Get-SPSSecret -CredentialKey $cfg.CredentialKey -ConfigPath $configFolder -ErrorAction Stop + if ($null -ne $cred -and $cred.GetNetworkCredential().Password.Length -gt 0) { + Add-CheckResult -Section 'Secrets' -Name "Credential '$($cfg.CredentialKey)'" -Status 'PASS' -Detail "DPAPI decrypt OK (user: $($cred.UserName))" + } + else { + Add-CheckResult -Section 'Secrets' -Name "Credential '$($cfg.CredentialKey)'" -Status 'FAIL' -Detail 'Not found in secrets.psd1' + } + } + catch { + Add-CheckResult -Section 'Secrets' -Name "Credential '$($cfg.CredentialKey)'" -Status 'FAIL' -Detail "Decrypt failed (wrong account/machine?): $($_.Exception.Message)" + } + } + } +} +else { + Add-CheckResult -Section 'Secrets' -Name 'CredentialKey' -Status 'SKIP' -Detail 'No CredentialKey in config' +} + +# 4. Privileges +Write-Section -Title 'Privileges' +$isAdmin = ([Security.Principal.WindowsPrincipal] [Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole] 'Administrator') +if ($isAdmin) { + Add-CheckResult -Section 'Privileges' -Name 'Administrator rights' -Status 'PASS' +} +else { + Add-CheckResult -Section 'Privileges' -Name 'Administrator rights' -Status 'FAIL' -Detail 'Run elevated (needed for the Event Log and SharePoint cmdlets)' +} + +# 5. Status store (UNC share for the live dashboard) +Write-Section -Title 'Status store' +if ($null -ne $cfg -and $cfg.Contains('StatusStorePath') -and -not [string]::IsNullOrWhiteSpace([string]$cfg.StatusStorePath)) { + $storePath = [string]$cfg.StatusStorePath + if (-not (Test-Path -Path $storePath)) { + Add-CheckResult -Section 'StatusStore' -Name 'Status store path' -Status 'FAIL' -Detail "Not reachable: $storePath" + } + else { + $probe = Join-Path -Path $storePath -ChildPath (".spsupdate-readiness-{0}.tmp" -f ([guid]::NewGuid().ToString('N'))) + try { + Set-Content -Path $probe -Value 'readiness' -ErrorAction Stop + Remove-Item -Path $probe -Force -ErrorAction SilentlyContinue + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (current user)' -Status 'PASS' -Detail $storePath + } + catch { + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (current user)' -Status 'FAIL' -Detail "Cannot write to $storePath : $($_.Exception.Message)" + } + + # CRITICAL: the four upgrade/mount sequence tasks run as the InstallAccount (the + # scheduled-task service account), not as the interactive user. If that account + # cannot write to the share, the sequences silently fail to publish their status + # and the dashboard never shows the upgrade phase. Probe write access AS the + # InstallAccount by launching a short process under its credential and checking + # whether the file actually lands on the share. + $svcCred = $null + if ($null -ne $cfg -and $cfg.Contains('CredentialKey') -and $cfg.CredentialKey -and (Get-Command -Name Get-SPSSecret -ErrorAction SilentlyContinue)) { + $configFolder2 = Split-Path -Path $ConfigFile -Parent + if ([string]::IsNullOrEmpty($configFolder2)) { $configFolder2 = '.' } + try { $svcCred = Get-SPSSecret -CredentialKey $cfg.CredentialKey -ConfigPath $configFolder2 -ErrorAction Stop } catch { $svcCred = $null } + } + + if ($null -eq $svcCred) { + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (service account)' -Status 'WARN' -Detail 'Could not load the InstallAccount to test; ensure it has Modify on the share (the sequence tasks run as that account)' + } + else { + $svcProbe = Join-Path -Path $storePath -ChildPath (".spsupdate-readiness-svc-{0}.tmp" -f ([guid]::NewGuid().ToString('N'))) + $svcCmd = "Set-Content -LiteralPath '$svcProbe' -Value 'readiness-svc' -ErrorAction Stop" + $encoded = [Convert]::ToBase64String([System.Text.Encoding]::Unicode.GetBytes($svcCmd)) + try { + $proc = Start-Process -FilePath "$env:SystemRoot\System32\WindowsPowerShell\v1.0\powershell.exe" ` + -Credential $svcCred ` + -WorkingDirectory "$env:SystemRoot" ` + -ArgumentList @('-NoProfile', '-NonInteractive', '-EncodedCommand', $encoded) ` + -Wait -PassThru -ErrorAction Stop + $null = $proc + Start-Sleep -Milliseconds 500 + if (Test-Path -Path $svcProbe) { + Remove-Item -Path $svcProbe -Force -ErrorAction SilentlyContinue + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (service account)' -Status 'PASS' -Detail "InstallAccount '$($svcCred.UserName)' can write to the share" + } + else { + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (service account)' -Status 'FAIL' -Detail "InstallAccount '$($svcCred.UserName)' cannot write to $storePath. Grant it Modify on the share + NTFS; otherwise the upgrade sequences will not appear on the dashboard." + } + } + catch { + Add-CheckResult -Section 'StatusStore' -Name 'Status store writable (service account)' -Status 'WARN' -Detail "Could not launch a probe as '$($svcCred.UserName)' ($($_.Exception.Message)). Verify it has 'Log on as a batch job' and Modify on the share." + } + } + } +} +else { + Add-CheckResult -Section 'StatusStore' -Name 'StatusStorePath' -Status 'WARN' -Detail 'Not set; the live dashboard will use the local Results\status folder and will not capture ProductUpdate on other servers' +} + +# 6. Network / CredSSP reachability +Write-Section -Title 'Network' +if ($SkipNetwork) { + Add-CheckResult -Section 'Network' -Name 'Farm reachability' -Status 'SKIP' -Detail '-SkipNetwork specified' +} +elseif ($null -ne $cfg -and $cfg.Contains('Domain') -and $cfg.Domain) { + $targets = New-Object System.Collections.Generic.List[string] + + if (-not $SkipSharePoint -and (Get-Command -Name Get-SPServer -ErrorAction SilentlyContinue)) { + try { + $farmServers = @(Get-SPServer | Where-Object { $_.Role -ne 'Invalid' } | Select-Object -ExpandProperty Name) + foreach ($s in $farmServers) { + $fqdn = if ($s -like '*.*') { $s } else { "$s.$($cfg.Domain)" } + if ($targets -notcontains $fqdn) { $targets.Add($fqdn) } + } + Add-CheckResult -Section 'Network' -Name 'Farm server enumeration' -Status 'PASS' -Detail "$($farmServers.Count) server(s) via Get-SPServer" + } + catch { + Add-CheckResult -Section 'Network' -Name 'Farm server enumeration' -Status 'SKIP' -Detail "Get-SPServer unavailable: $($_.Exception.Message)" + } + } + else { + Add-CheckResult -Section 'Network' -Name 'Farm server enumeration' -Status 'SKIP' -Detail 'SharePoint not loaded; cannot enumerate servers' + } + + foreach ($target in ($targets | Sort-Object -Unique)) { + $cim = $null + try { + $opt = New-CimSessionOption -Protocol Wsman + $cim = New-CimSession -ComputerName $target -OperationTimeoutSec $TimeoutSeconds -SessionOption $opt -ErrorAction Stop + Add-CheckResult -Section 'Network' -Name "WinRM to $target" -Status 'PASS' -Detail 'Confirm CredSSP is enabled for the full run' + } + catch { + Add-CheckResult -Section 'Network' -Name "WinRM to $target" -Status 'WARN' -Detail "Unreachable within ${TimeoutSeconds}s: $($_.Exception.Message)" + } + finally { + if ($cim) { Remove-CimSession -CimSession $cim -ErrorAction SilentlyContinue } + } + } +} +else { + Add-CheckResult -Section 'Network' -Name 'Farm reachability' -Status 'SKIP' -Detail 'No Domain to build server FQDNs' +} + +# Summary +$fail = @($script:results | Where-Object Status -eq 'FAIL').Count +$warn = @($script:results | Where-Object Status -eq 'WARN').Count +$pass = @($script:results | Where-Object Status -eq 'PASS').Count +Write-Host '' +Write-Host '============================================================' -ForegroundColor Cyan +Write-Host (' Summary : {0} passed, {1} warning(s), {2} failure(s)' -f $pass, $warn, $fail) -ForegroundColor Cyan +Write-Host '============================================================' -ForegroundColor Cyan + +if ($fail -gt 0) { exit 1 } else { exit 0 } diff --git a/tests/Export-SPSUpdateProgressReport.Tests.ps1 b/tests/Export-SPSUpdateProgressReport.Tests.ps1 new file mode 100644 index 0000000..7844b60 --- /dev/null +++ b/tests/Export-SPSUpdateProgressReport.Tests.ps1 @@ -0,0 +1,132 @@ +# Tests for the live patching dashboard (Export-SPSUpdateProgressReport). +# Cross-platform: server-rendered HTML from the local status store. + +BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $modulePath = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1' + Import-Module -Name $modulePath -Force + + $script:root = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("spsupd-dash-" + [guid]::NewGuid()) + New-Item -Path $script:root -ItemType Directory -Force | Out-Null + + function New-Campaign { + param([string]$Name) + $camp = Join-Path -Path $script:root -ChildPath $Name + New-Item -Path $camp -ItemType Directory -Force | Out-Null + return $camp + } +} + +AfterAll { + if ($script:root -and (Test-Path $script:root)) { + Remove-Item -Path $script:root -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-Module -Name SPSUpdate.Common -Force -ErrorAction SilentlyContinue +} + +Describe 'Export-SPSUpdateProgressReport (running campaign)' { + BeforeAll { + $script:camp = New-Campaign -Name 'run' + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'APP1' -State 'Done' -Item 'uber.exe' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'WFE1' -State 'Running' -Item 'uber.exe' -ItemState 'Running' -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Running' -Percent 50 -Item 'DB_A' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + + $script:out = Export-SPSUpdateProgressReport -CampaignPath $script:camp -EnvName 'PROD' -AppCode 'zebes' -FarmName 'CONTENT' -RefreshSeconds 15 + $script:html = Get-Content -Path $script:out -Raw + } + + It 'writes _dashboard.html in the campaign folder by default' { + $script:out | Should -Be (Join-Path -Path $script:camp -ChildPath '_dashboard.html') + Test-Path $script:out | Should -BeTrue + } + + It 'emits a meta-refresh while running' { + $script:html | Should -Match 'http-equiv="refresh" content="15"' + $script:html | Should -Match 'auto-refresh every 15s' + } + + It 'shows an overall Running state' { + $script:html | Should -Match 'class="badge Running"' + $script:html | Should -Match 'Overall state' + } + + It 'groups the phases and lists the servers/items' { + $script:html | Should -Match 'Product update' + $script:html | Should -Match 'Content database upgrade' + $script:html | Should -Match 'APP1' + $script:html | Should -Match 'WFE1' + $script:html | Should -Match 'DB_A' + $script:html | Should -Match '50%' + } + + It 'is a self-contained document with no external resources' { + $script:html | Should -Match '' + $script:html | Should -Not -Match 'src="http' + $script:html | Should -Not -Match 'href="http' + } +} + +Describe 'Export-SPSUpdateProgressReport (roll-up counts each unit once)' { + It 'does not double-count a scope and its single item' { + $camp = New-Campaign -Name 'count' + # Two servers, each a ProductUpdate scope with exactly one binary item, all Done. + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'APP1' -State 'Done' -Item 'uber.exe' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'SCH1' -State 'Done' -Item 'uber.exe' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + $h = Get-Content -Path (Export-SPSUpdateProgressReport -CampaignPath $camp) -Raw + # The 'Done' card must read 2 (one per server), not 4 (scope + item per server). + $h | Should -Match '
2
Done
' + } + + It 'counts an item-less scope (wizard) once via its scope state' { + $camp = New-Campaign -Name 'count-wizard' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Wizard' -Phase 'Wizard' -Server 'APP1' -State 'Done' -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Wizard' -Phase 'Wizard' -Server 'SCH1' -State 'Done' -Confirm:$false | Out-Null + $h = Get-Content -Path (Export-SPSUpdateProgressReport -CampaignPath $camp) -Raw + $h | Should -Match '
2
Done
' + } + + It 'stays overall Running when a scope is Running even if its items are all Done' { + # Reproduces the staggered sequence start: Sequence1 scope Running, its DBs all Done, + # but sequences 2-4 not started yet. Overall must not flip to Done prematurely. + $camp = New-Campaign -Name 'overall-running' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Running' -Percent 100 -Item 'DB_A' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -Item 'DB_B' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + $h = Get-Content -Path (Export-SPSUpdateProgressReport -CampaignPath $camp) -Raw + # Overall badge (first one, in the accent card) must be Running, and the Done count is 2. + $h | Should -Match 'card accent[^>]*>.*?' + $h | Should -Match '
2
Done
' + } +} + +Describe 'Export-SPSUpdateProgressReport (states)' { + It 'reports Failed overall when any item failed' { + $camp = New-Campaign -Name 'fail' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Failed' -Item 'DB_X' -ItemState 'Failed' -ItemDetail 'boom' -Confirm:$false | Out-Null + $out = Export-SPSUpdateProgressReport -CampaignPath $camp + $h = Get-Content -Path $out -Raw + $h | Should -Match 'class="badge Failed"' + } + + It 'disables auto-refresh and reports Done when -Completed' { + $camp = New-Campaign -Name 'done' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Wizard' -Phase 'Wizard' -Server 'APP1' -State 'Done' -Confirm:$false | Out-Null + $out = Export-SPSUpdateProgressReport -CampaignPath $camp -Completed + $h = Get-Content -Path $out -Raw + $h | Should -Not -Match 'http-equiv="refresh"' + $h | Should -Match 'Campaign completed' + } + + It 'renders a waiting message for an empty campaign' { + $camp = New-Campaign -Name 'empty' + $out = Export-SPSUpdateProgressReport -CampaignPath $camp + (Get-Content -Path $out -Raw) | Should -Match 'No status recorded yet' + } + + It 'HTML-encodes values coming from the status store' { + $camp = New-Campaign -Name 'enc' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Running' -Item 'DB & y' -ItemState 'Running' -Confirm:$false | Out-Null + $h = Get-Content -Path (Export-SPSUpdateProgressReport -CampaignPath $camp) -Raw + $h | Should -Match 'DB <x> & y' + $h | Should -Not -Match 'DB & y' + } +} diff --git a/tests/SPSUpdate.Common.Tests.ps1 b/tests/SPSUpdate.Common.Tests.ps1 index 7b97152..8905e09 100644 --- a/tests/SPSUpdate.Common.Tests.ps1 +++ b/tests/SPSUpdate.Common.Tests.ps1 @@ -37,7 +37,7 @@ Describe 'SPSUpdate.Common module' { } It 'manifest version is 4.0.0 or higher' { - (Test-ModuleManifest -Path $modulePath).Version | Should -BeGreaterOrEqual ([version]'4.0.0') + (Test-ModuleManifest -Path $modulePath).Version | Should -BeGreaterOrEqual ([version]'4.2.0') } It 'exports exactly the expected public functions' { @@ -46,14 +46,18 @@ Describe 'SPSUpdate.Common module' { 'Add-SPSUpdateEvent' 'Copy-SPSSideBySideFilesRemote' 'Export-SPSUpdateDbReport' + 'Export-SPSUpdateProgressReport' 'Get-SPSInstalledProductVersion' 'Get-SPSSecret' 'Get-SPSServersPatchStatus' + 'Get-SPSStatusCampaignPath' + 'Get-SPSUpdateStatus' 'Initialize-SPSContentDbJsonFile' 'Mount-SPSContentDatabase' 'Remove-SPSScheduledTask' 'Set-SPSSecret' 'Set-SPSSideBySideToken' + 'Set-SPSUpdateStatus' 'Start-SPSConfigExe' 'Start-SPSConfigExeRemote' 'Start-SPSProductUpdate' diff --git a/tests/SPSUpdateStatus.Tests.ps1 b/tests/SPSUpdateStatus.Tests.ps1 new file mode 100644 index 0000000..ae2ce55 --- /dev/null +++ b/tests/SPSUpdateStatus.Tests.ps1 @@ -0,0 +1,94 @@ +# Tests for the patching status store (Set-/Get-SPSUpdateStatus, Get-SPSStatusCampaignPath). +# Cross-platform: plain JSON files on the local filesystem. + +BeforeAll { + $repoRoot = Split-Path -Path $PSScriptRoot -Parent + $modulePath = Join-Path -Path $repoRoot -ChildPath 'src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1' + Import-Module -Name $modulePath -Force + + $script:root = Join-Path -Path ([System.IO.Path]::GetTempPath()) -ChildPath ("spsupd-status-" + [guid]::NewGuid()) + New-Item -Path $script:root -ItemType Directory -Force | Out-Null +} + +AfterAll { + if ($script:root -and (Test-Path $script:root)) { + Remove-Item -Path $script:root -Recurse -Force -ErrorAction SilentlyContinue + } + Remove-Module -Name SPSUpdate.Common -Force -ErrorAction SilentlyContinue +} + +Describe 'Get-SPSStatusCampaignPath' { + It 'falls back to Results\status when StatusStorePath is empty' { + $p = Get-SPSStatusCampaignPath -StatusStorePath '' -ResultsFolder $script:root -Application 'contoso' -Environment 'PROD' -FarmName 'CONTENT' + $p | Should -Be (Join-Path -Path (Join-Path -Path $script:root -ChildPath 'status') -ChildPath 'contoso-PROD-CONTENT') + } + + It 'uses the configured store path when provided' { + $store = Join-Path -Path $script:root -ChildPath 'share' + $p = Get-SPSStatusCampaignPath -StatusStorePath $store -ResultsFolder $script:root -Application 'contoso' -Environment 'PROD' -FarmName 'CONTENT' + $p | Should -Be (Join-Path -Path $store -ChildPath 'contoso-PROD-CONTENT') + } + + It 'creates the folder with -CreateIfMissing' { + $store = Join-Path -Path $script:root -ChildPath 'make' + $p = Get-SPSStatusCampaignPath -StatusStorePath $store -ResultsFolder $script:root -Application 'a' -Environment 'e' -FarmName 'f' -CreateIfMissing + Test-Path $p | Should -BeTrue + } +} + +Describe 'Set-SPSUpdateStatus / Get-SPSUpdateStatus round-trip' { + BeforeAll { + $script:camp = Join-Path -Path $script:root -ChildPath 'campaign1' + } + + It 'writes a scope file named __.json' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Running' -Confirm:$false | Out-Null + Test-Path (Join-Path -Path $script:camp -ChildPath 'APP1__Sequence1.json') | Should -BeTrue + } + + It 'accumulates items across calls inside the same scope' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -Item 'DB_A' -ItemState 'Done' -ItemDetail 'upgraded' -ExitCode 0 -Confirm:$false | Out-Null + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -State 'Done' -Item 'DB_B' -ItemState 'Done' -ExitCode 0 -Confirm:$false | Out-Null + + $all = Get-SPSUpdateStatus -CampaignPath $script:camp + $seq1 = $all | Where-Object { $_.Scope -eq 'Sequence1' } + @($seq1.Items).Count | Should -Be 2 + ($seq1.Items | Where-Object Name -eq 'DB_A').State | Should -Be 'Done' + $seq1.State | Should -Be 'Done' + } + + It 'updates an existing item in place rather than duplicating it' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Sequence1' -Phase 'Upgrade' -Server 'APP1' -Item 'DB_A' -ItemState 'Warning' -ItemDetail 'retried' -Confirm:$false | Out-Null + $seq1 = Get-SPSUpdateStatus -CampaignPath $script:camp | Where-Object { $_.Scope -eq 'Sequence1' } + @($seq1.Items | Where-Object Name -eq 'DB_A').Count | Should -Be 1 + ($seq1.Items | Where-Object Name -eq 'DB_A').State | Should -Be 'Warning' + } + + It 'keeps separate scopes and servers in separate files' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'ProductUpdate' -Phase 'ProductUpdate' -Server 'WFE1' -State 'Running' -Item 'uber.exe' -ItemState 'Running' -Confirm:$false | Out-Null + $all = Get-SPSUpdateStatus -CampaignPath $script:camp + ($all | Where-Object { $_.Server -eq 'WFE1' -and $_.Scope -eq 'ProductUpdate' }) | Should -Not -BeNullOrEmpty + @($all).Count | Should -Be 2 + } + + It 'records the optional percent' { + Set-SPSUpdateStatus -CampaignPath $script:camp -Scope 'Wizard' -Phase 'Wizard' -Server 'APP1' -State 'Running' -Percent 50 -Confirm:$false | Out-Null + $w = Get-SPSUpdateStatus -CampaignPath $script:camp | Where-Object { $_.Scope -eq 'Wizard' } + $w.Percent | Should -Be 50 + } +} + +Describe 'Get-SPSUpdateStatus resilience' { + It 'returns an empty array for a missing campaign folder' { + $missing = Join-Path -Path $script:root -ChildPath 'does-not-exist' + @(Get-SPSUpdateStatus -CampaignPath $missing).Count | Should -Be 0 + } + + It 'ignores temporary (*.tmp.*) files' { + $camp = Join-Path -Path $script:root -ChildPath 'campaign2' + New-Item -Path $camp -ItemType Directory -Force | Out-Null + Set-Content -Path (Join-Path $camp 'APP1__Wizard.json.tmp.abc') -Value '{ broken' + Set-SPSUpdateStatus -CampaignPath $camp -Scope 'Wizard' -Phase 'Wizard' -Server 'APP1' -State 'Done' -Confirm:$false | Out-Null + @(Get-SPSUpdateStatus -CampaignPath $camp).Count | Should -Be 1 + } +} diff --git a/wiki/Configuration.md b/wiki/Configuration.md index 12756da..259c864 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -127,6 +127,32 @@ by the side-by-side token. Zero downtime patching is a method of patching and up developed in SharePoint in Microsoft 365. For more details see [SharePoint Server zero downtime patching steps](https://learn.microsoft.com/en-us/sharepoint/upgrade-and-update/sharepoint-server-2016-zero-downtime-patching-steps). +## StatusStorePath (live dashboard) + +`StatusStorePath` is an OPTIONAL UNC share where every farm server writes its patching +progress so the master can assemble the near-real-time HTML dashboard. It must be writable +by the InstallAccount from every server. + +```powershell +StatusStorePath = '\\fileserver\spsupdate-status' +``` + +Leave it empty (or omit it) to fall back to a local `Results\status` folder; in that case +ProductUpdate runs launched on the other servers are not captured centrally. The status +files of one campaign live under `\--\`, with the +dashboard written there as `_dashboard.html`. See the +[Usage](./Usage) page for the campaign workflow and the `ResetStatus` action. + +### Required permissions + +Grant the **InstallAccount** (the account that runs the scheduled tasks) **Modify** rights +on the share — both the SMB **share** permission and the **NTFS** permission. The four +upgrade/mount sequence tasks run as that account, so if it cannot write to the share the +upgrade phase never appears on the dashboard (the ProductUpdate and Wizard sections, written +by your interactive/master run under your own account, still show — which can hide the +problem). Run `Test-SPSUpdateReadiness.ps1` to verify both your account and the InstallAccount +can write to the store before patching. + ## Next Step For the next steps, go to the [Usage](./Usage) page. diff --git a/wiki/Getting-Started.md b/wiki/Getting-Started.md index 33b9aac..c9ef384 100644 --- a/wiki/Getting-Started.md +++ b/wiki/Getting-Started.md @@ -7,6 +7,7 @@ - Administrative privileges on each SharePoint Server - A service account (`InstallAccount`) whose credential is stored in `Config\secrets.psd1` - SharePoint update binaries copied to a local or accessible path (if using `ProductUpdate`) +- (Optional, for the live dashboard) a UNC share for the status store with **Modify** rights for the InstallAccount — see [Configuration](./Configuration#statusstorepath-live-dashboard) ## Configure CredSSP diff --git a/wiki/Usage.md b/wiki/Usage.md index 641373e..812919a 100644 --- a/wiki/Usage.md +++ b/wiki/Usage.md @@ -122,6 +122,82 @@ dedicated **`SPSUpdate` Windows Event Log** via `Add-SPSUpdateEvent`. - Test the script in a non-production environment before deploying it widely. +## Near real-time patching dashboard + +When a status store is configured, SPSUpdate records the progress of every phase +(ProductUpdate per server, the four mount/upgrade sequences, the Configuration Wizard +per server, and side-by-side) into a shared location, and the master assembles a live +HTML dashboard you can open in a browser while patching runs. + +### Configure the status store + +Set `StatusStorePath` in the environment config to a UNC share writable by the +InstallAccount from every farm server: + +```powershell +StatusStorePath = '\\fileserver\spsupdate-status' +``` + +If left empty, SPSUpdate falls back to a local `Results\status` folder. In that case the +master still shows the mount/upgrade/wizard phases, but ProductUpdate runs launched on the +other servers are not captured centrally (they would each write to their own local folder). + +The status files of one patching campaign live under +`\--\`, and the dashboard is written there as +`_dashboard.html`. + +> [!IMPORTANT] +> **The InstallAccount (the scheduled-task service account) must have Modify rights on +> the share — both the SMB share permission and the NTFS permission.** The four +> upgrade/mount sequence tasks run as that account, so if it cannot write to the store the +> sequences silently fail to publish their status and the **Content database upgrade phase +> never appears on the dashboard** (only the ProductUpdate and Wizard sections, written by +> the interactive/master run, show up). The ProductUpdate and Default runs you launch +> interactively write under your own account, which can mask the problem during testing. +> `Test-SPSUpdateReadiness.ps1` probes write access both as the current user **and** as the +> InstallAccount to catch this up front. + +### Run a patched campaign with the dashboard + +1. **Reset** the campaign once (clears any previous run): + + ```powershell + .\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action ResetStatus + ``` + +2. Open `\\--\_dashboard.html` in a browser. It + auto-refreshes every 15 seconds while the campaign is in progress. + +3. Install the binaries on **each** server (each writes its ProductUpdate progress to the + share): + + ```powershell + .\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' -Action ProductUpdate + ``` + +4. Run the master upgrade on the master server. It regenerates the dashboard as the four + sequences, the Configuration Wizard and the side-by-side step progress, and turns the + auto-refresh off with a final state when finished: + + ```powershell + .\SPSUpdate.ps1 -ConfigFile 'CONTOSO-PROD-CONTENT.psd1' + ``` + +The dashboard shows the overall state, per-server / per-sequence progress, per-database and +per-binary item states with exit codes, and a completion percentage for each sequence. + +## Pre-flight readiness check + +`Test-SPSUpdateReadiness.ps1` validates the environment before a run (read-only). It checks +the module import, the config keys, the DPAPI secret, elevation, the status store +reachability and write access, and the per-server WinRM/CredSSP reachability: + +```powershell +.\Test-SPSUpdateReadiness.ps1 -ConfigFile 'Config\CONTOSO-PROD-CONTENT.psd1' +``` + +It exits non-zero when any check fails, so it can gate an automated rollout. + ## Support For issues or questions, open an [issue](https://github.com/luigilink/SPSUpdate/issues) or