From 9e8c158a75b9102ebfe87dedbe45ea87af0471b1 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Wed, 7 Oct 2026 11:26:42 +0200 Subject: [PATCH 1/3] Reconcile a deferred reboot already performed out-of-band When a reboot was deferred (install returned 17022 outside the Reboot.Schedule window), a .pending marker is written and honoured on the next in-window run. If the server was rebooted out-of-band in the meantime (manual restart or Windows Update), SPSUpdate restarted it again unnecessarily. Before acting on a deferred pending request (no fresh -RebootRequired this run), compare the server's LastBootUpTime with the .pending marker timestamp: if the server booted after the request was recorded, mark the Reboot phase Done ('Reboot already completed out-of-band'), clear the pending marker and skip the restart. A reboot required by an install performed this run is unaffected. Refs #68 --- CHANGELOG.md | 1 + src/SPSUpdate.ps1 | 18 ++++++++++++++++++ 2 files changed, 19 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index f07c472..0a21215 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- A deferred automatic reboot that was already performed **out-of-band** (a manual restart or a Windows Update reboot) is now reconciled instead of restarting the server again: when a `.pending` reboot request predates the server's last boot time, `Invoke-SPSAutomaticReboot` marks the Reboot phase `Done` ("Reboot already completed out-of-band"), clears the pending marker and skips the restart. A fresh reboot required by an install performed in the same run is unaffected. ([#68](https://github.com/luigilink/SPSUpdate/issues/68)) - `-Action ProductUpdate` now refreshes the installed server's live **Patch Status** on the Binaries Installation card after the cumulative update is installed, so the column reflects the real post-install state (typically `Upgrade Required` until the Configuration Wizard runs) instead of keeping the pre-install value (`No Action Required`) until the next `Default` run. It reuses the same master-side patch-status read as the `Default` baseline (no remoting) and is status-only — the Content Databases and Configuration Wizard cards still evolve only during a `Default` run. A failed read preserves the last-known-good value. ([#59](https://github.com/luigilink/SPSUpdate/issues/59)) - The near real-time dashboard now refreshes the live farm status at key transitions (the pre-patch baseline, after the Configuration Wizard completes, and the final render) instead of only at the pre-patch baseline. The master re-reads each server's patch status and re-publishes the content-database inventory with fresh upgrade status, so a successful campaign no longer shows a stale `Upgrade Required` Patch Status or a lingering pre-patch inconsistency banner once the farm is back to `No Action Required`. The Content Databases card also shows a database that SPSUpdate has already upgraded (State `Done`) as `No update pending` even before the next refresh. Server patch status and role are shown with short readable labels (for example `Upgrade Required`, `Application with Search`) via the new public helpers `ConvertTo-SPSPatchStatusLabel` and `ConvertTo-SPSRoleLabel`, instead of the raw enum names. ([#51](https://github.com/luigilink/SPSUpdate/issues/51)) - A standalone, idempotent helper `New-SPSDashboardSite.ps1` (in `src/`) that provisions the dashboard hosting target on an IIS/pull server — folder, SMB share (Modify for the write accounts), NTFS permissions, a static-file `web.config`, and either a dedicated IIS site or a sub-application under an existing site. It has no dependency on the `SPSUpdate.Common` module, so it can be copied on its own to the IIS server. A new wiki page, *Hosting the dashboard on IIS*, documents the validated setup (and the manual equivalent). ([#49](https://github.com/luigilink/SPSUpdate/issues/49)) diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index d1ff9d6..ef46943 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -632,6 +632,24 @@ function Invoke-SPSAutomaticReboot { return } + # Reconcile a deferred reboot already performed out-of-band: if the pending request predates + # the last boot, the required restart has happened (manual / Windows Update) - mark it Done. + if ($hasPending -and -not $RebootRequired) { + $pendingStamp = $null + try { $pendingStamp = [datetime]::Parse((Get-Content -Path $rebootPendingMarker -Raw).Trim(), $null, [System.Globalization.DateTimeStyles]::RoundtripKind) } catch { $pendingStamp = $null } + $lastBoot = $null + try { $lastBoot = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime } catch { $lastBoot = $null } + if ($null -ne $pendingStamp -and $null -ne $lastBoot -and $lastBoot -gt $pendingStamp) { + Write-Output "Deferred reboot for $thisServer already satisfied out-of-band (last boot $lastBoot > request $pendingStamp); reconciling." + if ($null -ne $rebootDoneMarker) { Set-Content -Path $rebootDoneMarker -Value (Get-Date -Format o) -Force -ErrorAction SilentlyContinue } + Remove-Item -Path $rebootPendingMarker -Force -ErrorAction SilentlyContinue + Write-SPSStatus -Scope 'Reboot' -Phase 'Reboot' -Server $thisServer -State 'Done' -Detail 'Reboot already completed out-of-band' + Add-SPSUpdateEvent -Message "Deferred reboot on $thisServer reconciled: the server was already rebooted out-of-band." -Source 'Restart-SPSServer' -EntryType 'Information' -EventID 3010 + Write-SPSDashboard + return + } + } + # Decide whether a reboot is warranted. Force reboots even on a successful no-reboot # install (exit 0), but only when an install actually ran this time, so an already-patched # server (null / exit 17025) is never rebooted just because a new campaign started. From 446cf76072a8be597d3093fddb80025cf9bf768f Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Wed, 7 Oct 2026 11:43:27 +0200 Subject: [PATCH 2/3] Do not reconcile when this run needs a fresh reboot Out-of-band reconciliation only applies to a stale deferred request. If this run performed an install and Reboot.Force is enabled (exit 0, no 17022), the freshly installed binaries still need a restart; excluding (Force -and InstallPerformed) lets that case fall through to the normal reboot/schedule decision instead of being marked Done by a stale, already-satisfied pending marker. Refs #68 --- src/SPSUpdate.ps1 | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index ef46943..c817f0b 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -632,9 +632,9 @@ function Invoke-SPSAutomaticReboot { return } - # Reconcile a deferred reboot already performed out-of-band: if the pending request predates - # the last boot, the required restart has happened (manual / Windows Update) - mark it Done. - if ($hasPending -and -not $RebootRequired) { + # Reconcile a deferred reboot already performed out-of-band (pending predates last boot), but + # not when this run itself needs a reboot (fresh -RebootRequired, or Force on a successful install). + if ($hasPending -and -not $RebootRequired -and -not ($rebootCfg.Force -and $InstallPerformed)) { $pendingStamp = $null try { $pendingStamp = [datetime]::Parse((Get-Content -Path $rebootPendingMarker -Raw).Trim(), $null, [System.Globalization.DateTimeStyles]::RoundtripKind) } catch { $pendingStamp = $null } $lastBoot = $null From 4702bcc1aef7e00d8a904b20db790016b9d73bb7 Mon Sep 17 00:00:00 2001 From: LuigiLink Date: Wed, 7 Oct 2026 12:17:06 +0200 Subject: [PATCH 3/3] Compare reboot timestamps in UTC The .pending marker carries an offset (Get-Date -Format o) while CIM LastBootUpTime is local. Comparing local wall-clock ticks can miss a completed reboot during the DST fall-back hour (an earlier local tick for a physically later boot). Normalize the pending timestamp (DateTimeOffset.UtcDateTime) and LastBootUpTime (ToUniversalTime) to UTC before comparing. Refs #68 --- src/SPSUpdate.ps1 | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index c817f0b..682f5d9 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -636,9 +636,9 @@ function Invoke-SPSAutomaticReboot { # not when this run itself needs a reboot (fresh -RebootRequired, or Force on a successful install). if ($hasPending -and -not $RebootRequired -and -not ($rebootCfg.Force -and $InstallPerformed)) { $pendingStamp = $null - try { $pendingStamp = [datetime]::Parse((Get-Content -Path $rebootPendingMarker -Raw).Trim(), $null, [System.Globalization.DateTimeStyles]::RoundtripKind) } catch { $pendingStamp = $null } + try { $pendingStamp = [datetimeoffset]::Parse((Get-Content -Path $rebootPendingMarker -Raw).Trim(), [cultureinfo]::InvariantCulture, [System.Globalization.DateTimeStyles]::RoundtripKind).UtcDateTime } catch { $pendingStamp = $null } $lastBoot = $null - try { $lastBoot = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime } catch { $lastBoot = $null } + try { $lastBoot = (Get-CimInstance -ClassName Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime.ToUniversalTime() } catch { $lastBoot = $null } if ($null -ne $pendingStamp -and $null -ne $lastBoot -and $lastBoot -gt $pendingStamp) { Write-Output "Deferred reboot for $thisServer already satisfied out-of-band (last boot $lastBoot > request $pendingStamp); reconciling." if ($null -ne $rebootDoneMarker) { Set-Content -Path $rebootDoneMarker -Value (Get-Date -Format o) -Force -ErrorAction SilentlyContinue }