Skip to content

Commit e69293a

Browse files
authored
Failover Clustering safety: quorum guards + CSV data-loss guard + witness validation (v1.121.6) (#74)
Deep audit found 7 real bugs (3 high). Quorum-preserving drain + eviction, CSV-removal data-loss guard, disk/file-share witness validation, correct redirected-I/O detection, validation-report path. Each fix mirrors a correct pattern already in the module.
1 parent e6dd7d7 commit e69293a

9 files changed

Lines changed: 145 additions & 15 deletions

Changelog.md

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,18 @@
11
# Changelog
22

3+
## v1.121.6
4+
5+
Failover Clustering safety — fixes found by a deep audit of the clustering module.
6+
7+
- **Draining a node from the dashboard now protects quorum.** It refuses to drain if no other node is up to receive the roles, and warns (requiring you to type CONTINUE) if draining would drop the cluster below quorum — instead of a single yes/no that could take the whole cluster offline.
8+
- **Node eviction won't silently break quorum on larger clusters.** The safety check was hardcoded for small clusters; it now calculates quorum for the actual cluster size, so it can't be bypassed on 4+ node clusters.
9+
- **Removing a Cluster Shared Volume can't destroy live-VM storage on false information.** If the tool can't determine the CSV's mount point (e.g. it's offline/degraded), it now refuses the removal rather than reporting "no VMs" from a check that never actually ran.
10+
- **Quorum witness validation.** A disk witness must be Online before it can be selected; a file-share witness path is checked for reachability up front, with a reminder that the cluster account needs Full Control on the share.
11+
- **Correct CSV redirected-I/O detection.** The dashboard no longer shows a false "REDIRECTED I/O ACTIVE" warning on healthy volumes (it was reading the wrong property).
12+
- **Cluster validation report location is now shown** so you can actually find the report.
13+
14+
No module or CLI action changes (81 modules, 201 actions).
15+
316
## v1.121.5
417

518
Reliability sweep — fixes found by auditing every menu for the same class of issues you ran into.

Header.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,7 @@
3030
7h3 4b1d3r
3131
3232
.VERSION
33-
1.121.5
33+
1.121.6
3434
.LAST UPDATED
3535
07/01/2026
3636

Modules/00-Initialization.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -233,7 +233,7 @@ if (-not $PSCommandPath -and $script:ScriptPath) {
233233
if (-not $script:ModuleRoot -and $script:ScriptPath) {
234234
$script:ModuleRoot = [System.IO.Path]::GetDirectoryName($script:ScriptPath)
235235
}
236-
$script:ScriptVersion = "1.121.5"
236+
$script:ScriptVersion = "1.121.6"
237237
$script:ScriptStartTime = Get-Date
238238

239239
# Post-update cleanup: UpdateSelf / Rollback leave a `.pending-delete` sibling next to RackStack.exe.

Modules/27-FailoverClustering.ps1

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -298,8 +298,11 @@ function New-ClusterWizard {
298298
Write-OutputColor "" -color "Info"
299299
Write-OutputColor " Running cluster validation (this may take several minutes)..." -color "Info"
300300
try {
301-
Test-Cluster -Node $nodes -ReportName "ClusterValidation_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
302-
Write-OutputColor " Validation complete. Check the report for any issues." -color "Success"
301+
# Save the validation report to a known location and tell the operator where it is (a
302+
# bare -ReportName drops the .htm in an unstated default dir). Mirrors Test-ClusterValidation.
303+
$valReportPath = Join-Path $script:TempPath "ClusterValidation_$(Get-Date -Format 'yyyyMMdd_HHmmss')"
304+
Test-Cluster -Node $nodes -ReportName $valReportPath | Out-Null
305+
Write-OutputColor " Validation complete. Report saved to: $valReportPath.htm" -color "Success"
303306
Write-OutputColor "" -color "Info"
304307
if (-not (Confirm-UserAction -Message "Proceed with cluster creation?")) {
305308
return
@@ -387,9 +390,18 @@ function Add-NodeToCluster {
387390
return
388391
}
389392
$upNodes = @($liveNodes | Where-Object { $_.State -eq 'Up' })
390-
if ($upNodes.Count -le 2) {
391-
Write-OutputColor " REFUSING to evict: only $($upNodes.Count) node(s) currently Up." -color "Error"
392-
Write-OutputColor " Evicting '$Node' would leave the cluster without quorum." -color "Error"
393+
# Size-aware quorum check. Eviction REMOVES a node, so compute quorum against the
394+
# post-eviction node count. The old '-le 2' test only protected 2-3 node clusters — on a
395+
# 5-node cluster with 3 Up it passed, but evicting one leaves 4 nodes / 2 Up while quorum
396+
# needs floor(4/2)+1 = 3, silently losing quorum.
397+
$totalAfter = $liveNodes.Count - 1
398+
$nodeWasUp = @($upNodes | Where-Object { $_.Name -eq $Node }).Count -gt 0
399+
$upAfter = if ($nodeWasUp) { $upNodes.Count - 1 } else { $upNodes.Count }
400+
$quorumAfter = [math]::Floor($totalAfter / 2) + 1
401+
if ($totalAfter -lt 1 -or $upAfter -lt $quorumAfter) {
402+
Write-OutputColor " REFUSING to evict '$Node'." -color "Error"
403+
Write-OutputColor " After eviction the cluster would have $upAfter/$totalAfter Up node(s), but quorum requires $quorumAfter." -color "Error"
404+
Write-OutputColor " This would leave the cluster without quorum." -color "Error"
393405
Write-OutputColor " If this is intentional, evict manually via Failover Cluster Manager." -color "Warning"
394406
return
395407
}
@@ -579,6 +591,17 @@ function Edit-ClusterSharedVolume {
579591
$csvMountRoot = $info.FriendlyVolumeName.TrimEnd('\')
580592
}
581593
} catch { }
594+
# If we can't resolve the CSV mount point, the VM-dependency scan below would
595+
# match nothing and falsely report "no VMs" — the operator would then confirm a
596+
# removal on false information and could destroy live-VM storage. Refuse instead.
597+
if (-not $csvMountRoot) {
598+
Write-OutputColor "" -color "Error"
599+
Write-OutputColor " Could not determine the mount point of CSV '$csvName'." -color "Error"
600+
Write-OutputColor " Cannot verify whether any VMs depend on it, so removal is refused for safety." -color "Error"
601+
Write-OutputColor " (Usually means the CSV is offline/degraded — check its health first.)" -color "Warning"
602+
Write-PressEnter
603+
return
604+
}
582605
$dependentVMs = @()
583606
try {
584607
$clusteredVMs = @(Get-ClusterGroup -ErrorAction SilentlyContinue | Where-Object { $_.GroupType -eq 'VirtualMachine' })
@@ -972,7 +995,8 @@ function Set-ClusterQuorumConfig {
972995
Write-OutputColor "" -color "Info"
973996
$idx = 1
974997
foreach ($disk in $disks) {
975-
Write-OutputColor " [$idx] $($disk.Name)" -color "Info"
998+
$diskStateColor = if ($disk.State -eq 'Online') { "Info" } else { "Warning" }
999+
Write-OutputColor " [$idx] $($disk.Name) [$($disk.State)]" -color $diskStateColor
9761000
$idx++
9771001
}
9781002
Write-OutputColor "" -color "Info"
@@ -982,6 +1006,15 @@ function Set-ClusterQuorumConfig {
9821006
if ($diskChoice -match '^\d+$') {
9831007
$selIdx = [int]$diskChoice - 1
9841008
if ($selIdx -ge 0 -and $selIdx -lt $disks.Count) {
1009+
# A disk witness that is Offline/Failed still counts as a vote — if it's not
1010+
# actually available, the cluster loses quorum when that vote is needed. Refuse
1011+
# to nominate a non-Online disk as the witness.
1012+
if ($disks[$selIdx].State -ne "Online") {
1013+
Write-OutputColor " Disk '$($disks[$selIdx].Name)' is not Online (State: $($disks[$selIdx].State))." -color "Error"
1014+
Write-OutputColor " An Offline/Failed disk cannot serve as the quorum witness — bring it Online first." -color "Warning"
1015+
Write-PressEnter
1016+
return
1017+
}
9851018
try {
9861019
Set-ClusterQuorum -NodeAndDiskMajority $disks[$selIdx].Name -ErrorAction Stop
9871020
Write-OutputColor " Quorum set to Node and Disk Majority." -color "Success"
@@ -1009,6 +1042,17 @@ function Set-ClusterQuorumConfig {
10091042
break
10101043
}
10111044
if ($sharePath) {
1045+
# Pre-flight: a UNC that merely LOOKS valid isn't enough. The classic file-share-
1046+
# witness failure is a reachable share that doesn't grant the cluster name object
1047+
# (CNO) permission — the witness vote then fails at the critical moment. Verify
1048+
# reachability and remind about the ACL before committing.
1049+
if (-not (Test-Path -LiteralPath $sharePath)) {
1050+
Write-OutputColor " Share '$sharePath' is not reachable from this node." -color "Error"
1051+
Write-OutputColor " Create/share it and confirm network access, then retry." -color "Warning"
1052+
break
1053+
}
1054+
Write-OutputColor " Reminder: the cluster name object (CNO, e.g. CLUSTERNAME`$) needs Full Control" -color "Warning"
1055+
Write-OutputColor " on this share (both the SMB share and NTFS ACLs), or the witness vote will fail." -color "Warning"
10121056
try {
10131057
Set-ClusterQuorum -NodeAndFileShareMajority $sharePath -ErrorAction Stop
10141058
Write-OutputColor " Quorum set to Node and File Share Majority." -color "Success"

Modules/51-ClusterDashboard.ps1

Lines changed: 37 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,34 @@ function Start-ClusterNodeDrain {
196196

197197
$selectedNode = $nodeMap[$choice]
198198

199+
# Safety: refuse to drain if no OTHER Up node can host the migrated roles, and require a typed
200+
# CONTINUE if draining would drop the cluster below quorum — a bare yes/no confirm is not enough
201+
# for a quorum-affecting operation. ($nodes above is Up-only, so re-query ALL nodes for the
202+
# quorum math.) Mirrors Suspend-ClusterNodeForMaintenance in 27-FailoverClustering.ps1.
203+
$clusterNodes = @(Get-ClusterNode -ErrorAction SilentlyContinue)
204+
$otherUp = @($clusterNodes | Where-Object { $_.State -eq 'Up' -and $_.Name -ne $selectedNode })
205+
if ($otherUp.Count -eq 0) {
206+
Write-OutputColor "" -color "Error"
207+
Write-OutputColor " REFUSING to drain '$selectedNode': no other Up node is available to host its roles." -color "Error"
208+
Write-OutputColor " Draining would leave roles unhosted and take the cluster offline." -color "Error"
209+
Write-PressEnter
210+
return
211+
}
212+
if ($clusterNodes.Count -gt 0) {
213+
$quorumThreshold = [math]::Floor($clusterNodes.Count / 2) + 1
214+
if ($otherUp.Count -lt $quorumThreshold) {
215+
Write-OutputColor "" -color "Warning"
216+
Write-OutputColor " CAUTION: after draining, only $($otherUp.Count)/$($clusterNodes.Count) nodes will be Up" -color "Warning"
217+
Write-OutputColor " (quorum requires $quorumThreshold). The cluster may halt if any other node fails." -color "Warning"
218+
Write-OutputColor " Type CONTINUE to proceed:" -color "Warning"
219+
$drainConfirm = Read-Host
220+
if ($drainConfirm -ne 'CONTINUE') {
221+
Write-OutputColor " Cancelled." -color "Info"
222+
return
223+
}
224+
}
225+
}
226+
199227
Write-OutputColor "" -color "Info"
200228
Write-OutputColor " Draining node: $selectedNode" -color "Warning"
201229
Write-OutputColor " This will migrate all VMs to other nodes and pause the node." -color "Info"
@@ -315,7 +343,13 @@ function Show-CSVHealth {
315343

316344
foreach ($csv in $csvs) {
317345
$partition = $csv.SharedVolumeInfo.Partition
318-
$redirected = $csv.SharedVolumeInfo.FaultState
346+
# Correct redirected-I/O detection: SharedVolumeInfo.FaultState reports fault status (e.g.
347+
# "NoFaults"), never a redirection string, so the old '-ne "NoRedirectedAccess"' test warned
348+
# on EVERY healthy CSV. Use Get-ClusterSharedVolumeState.FileSystemRedirectedIOReason — the
349+
# API this module already uses correctly elsewhere.
350+
$redirectedState = @($csv | Get-ClusterSharedVolumeState -ErrorAction SilentlyContinue) |
351+
Where-Object { $_.FileSystemRedirectedIOReason -and $_.FileSystemRedirectedIOReason -ne 'NotRedirected' } |
352+
Select-Object -First 1
319353
if (-not $partition) {
320354
$lineStr = " $($csv.Name) - Partition info unavailable"
321355
if ($lineStr.Length -gt 69) { $lineStr = $lineStr.Substring(0, 69) + "..." }
@@ -348,8 +382,8 @@ function Show-CSVHealth {
348382
Write-OutputColor "$(" Space: ${usedGB}GB used / ${totalGB}GB total (${usedPct}% used)".PadRight(72))" -color $spaceColor
349383
Write-OutputColor "$(" Free: ${freeGB}GB".PadRight(72))" -color $spaceColor
350384

351-
# Redirected I/O warning
352-
if ($redirected -ne "NoRedirectedAccess") {
385+
# Redirected I/O warning (only when genuinely redirected)
386+
if ($redirectedState) {
353387
Write-OutputColor "$(" ⚠ REDIRECTED I/O ACTIVE - Performance degraded!".PadRight(72))" -color "Error"
354388
}
355389

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@
2828
<a href="https://www.bestpractices.dev/projects/12921"><img alt="OpenSSF Best Practices" src="https://www.bestpractices.dev/projects/12921/badge"></a>
2929
<a href="https://codecov.io/gh/TheAbider/RackStack"><img alt="codecov" src="https://codecov.io/gh/TheAbider/RackStack/branch/master/graph/badge.svg"></a>
3030
<img alt="PSScriptAnalyzer 0 errors" src="https://img.shields.io/badge/PSScriptAnalyzer-0%20errors-brightgreen">
31-
<img alt="5269 structural tests" src="https://img.shields.io/badge/structural%20tests-5269-brightgreen">
31+
<img alt="5279 structural tests" src="https://img.shields.io/badge/structural%20tests-5279-brightgreen">
3232
<img alt="Pester 312 tests" src="https://img.shields.io/badge/Pester-312%20tests-brightgreen">
3333
<img alt="SLSA Level 3" src="https://slsa.dev/images/gh-badge-level3.svg">
3434
</p>

RackStack.ps1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
Environment-specific settings are configured via defaults.json.
1414
1515
.VERSION
16-
1.121.5
16+
1.121.6
1717
.NOTES
1818
- Requires Windows Server 2012 R2 or later (or Windows 10/11 for testing)
1919
- Must be run as Administrator

RackStack.psd1

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
@{
22
RootModule = 'RackStack.psm1'
3-
ModuleVersion = '1.121.5'
3+
ModuleVersion = '1.121.6'
44
GUID = 'c19b8e71-4a35-4f2b-9d06-8a24f7bc0e91'
55
Author = 'TheAbider'
66
CompanyName = 'TheAbider'

Tests/Run-Tests.ps1

Lines changed: 40 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<#
22
.SYNOPSIS
3-
Automated Test Runner for RackStack v1.121.5
3+
Automated Test Runner for RackStack v1.121.6
44

55
.DESCRIPTION
66
Comprehensive non-interactive test suite covering:
@@ -9740,6 +9740,45 @@ catch {
97409740
Write-TestResult "Reliability Sweep Tests" $false $_.Exception.Message
97419741
}
97429742

9743+
# ============================================================================
9744+
# SECTION 193: FAILOVER CLUSTERING SAFETY (v1.121.6)
9745+
# ============================================================================
9746+
# Guards the cluster-safety fixes: quorum-preserving drain/evict, CSV-removal data-loss guard,
9747+
# disk/file-share witness validation, correct CSV redirected-I/O detection, report path.
9748+
Write-SectionHeader "SECTION 193: FAILOVER CLUSTERING SAFETY"
9749+
9750+
try {
9751+
$fcR = Get-Content "$modulesPath\27-FailoverClustering.ps1" -Raw
9752+
$cdR = Get-Content "$modulesPath\51-ClusterDashboard.ps1" -Raw
9753+
9754+
# Dashboard node-drain now checks other-Up-nodes + quorum before Suspend-ClusterNode -Drain.
9755+
Write-TestResult "51-ClusterDashboard: drain refuses when no other Up node" ($cdR -match 'REFUSING to drain[\s\S]{0,1500}Suspend-ClusterNode')
9756+
Write-TestResult "51-ClusterDashboard: drain has quorum-threshold gate" ($cdR -match 'quorumThreshold = \[math\]::Floor[\s\S]{0,1200}Suspend-ClusterNode')
9757+
9758+
# Node-eviction undo uses size-aware post-eviction quorum math (no hardcoded -le 2).
9759+
Write-TestResult "27-FC: eviction undo is size-aware (no hardcoded -le 2)" ($fcR -match '\$quorumAfter = \[math\]::Floor\(\$totalAfter / 2\) \+ 1' -and -not ($fcR -match 'if \(\$upNodes\.Count -le 2\)'))
9760+
9761+
# CSV removal aborts when the mount point can't be resolved (no false 'no VMs').
9762+
Write-TestResult "27-FC: CSV removal aborts on unresolved mount point" ($fcR -match 'if \(-not \$csvMountRoot\)[\s\S]{0,300}refused for safety')
9763+
9764+
# Disk witness must be Online before being nominated.
9765+
Write-TestResult "27-FC: disk witness requires Online state" ($fcR -match 'State -ne "Online"[\s\S]{0,300}Set-ClusterQuorum -NodeAndDiskMajority' -or $fcR -match 'State -ne "Online"[\s\S]{0,300}cannot serve as the quorum witness')
9766+
9767+
# File-share witness has a reachability pre-flight + CNO ACL reminder.
9768+
Write-TestResult "27-FC: file-share witness pre-flights reachability" ($fcR -match 'Test-Path -LiteralPath \$sharePath[\s\S]{0,700}Set-ClusterQuorum -NodeAndFileShareMajority')
9769+
Write-TestResult "27-FC: file-share witness reminds about CNO ACL" ($fcR -match 'cluster name object \(CNO')
9770+
9771+
# CSV redirected-I/O uses the correct API (not the always-true FaultState comparison).
9772+
Write-TestResult "51-ClusterDashboard: CSV redirect uses Get-ClusterSharedVolumeState" ($cdR -match 'Get-ClusterSharedVolumeState[\s\S]{0,200}FileSystemRedirectedIOReason')
9773+
Write-TestResult "51-ClusterDashboard: no bogus FaultState redirect check" (-not ($cdR -match '\$redirected -ne "NoRedirectedAccess"'))
9774+
9775+
# Cluster validation report is saved to a known path and its location shown.
9776+
Write-TestResult "27-FC: wizard validation report path is shown" ($fcR -match 'Test-Cluster -Node \$nodes -ReportName \$valReportPath[\s\S]{0,120}Report saved to')
9777+
}
9778+
catch {
9779+
Write-TestResult "Failover Clustering Safety Tests" $false $_.Exception.Message
9780+
}
9781+
97439782
# ============================================================================
97449783
# SECTION 174: DOCUMENTATION FRESHNESS (counts must match the codebase)
97459784
# ============================================================================

0 commit comments

Comments
 (0)