diff --git a/CHANGELOG.md b/CHANGELOG.md index 90918e0..8ebbe55 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Changed + +- All Boolean configuration switches now accept `$true`/`$false` **and** `1`/`0`, and reject every other value with a clear error naming the property. Previously the `Reboot`, `Remoting` and `Execution` switches (`Reboot.Enable`, `Reboot.Force`, `Remoting.AllowFallback`, `Execution.InteractiveSequences`) enforced a strict `[bool]` type and threw on `1`, while the older flags (`Binaries.ProductUpdate`, `Binaries.ShutdownServices`, `MountContentDatabase`, `UpgradeContentDatabase`, `SideBySideToken.Enable`) silently coerced any truthy value (so `1` worked but so did a typo like `'false'`, which evaluates to `$true`). A shared `ConvertTo-SPSConfigBoolean` helper now validates and normalizes every switch consistently. ([#76](https://github.com/luigilink/SPSUpdate/issues/76)) + ## [5.2.0] - 2026-10-07 ### Changed diff --git a/src/Config/CONTOSO-PROD.example.psd1 b/src/Config/CONTOSO-PROD.example.psd1 index 34ca81c..e39f116 100644 --- a/src/Config/CONTOSO-PROD.example.psd1 +++ b/src/Config/CONTOSO-PROD.example.psd1 @@ -62,7 +62,7 @@ # --- Binaries (REQUIRED block; used by -Action ProductUpdate) --------------------- Binaries = @{ # ProductUpdate : allow the binary installation step. - # Possible values : $true | $false. Default if omitted: $true + # Possible values : $true | $false (1 | 0 also accepted). Default if omitted: $true ProductUpdate = $true # SetupFullPath : folder (local to each server) that holds the update binaries. @@ -80,7 +80,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 + # Possible values : $true | $false (1 | 0 also accepted). Default if omitted: $true ShutdownServices = $true # Schedule : OPTIONAL window restricting WHEN the binary install may run. Omit to @@ -108,12 +108,12 @@ # completion is not recorded until the grant is added. Reboot = @{ # Enable : allow SPSUpdate to reboot a server automatically after a CU install. - # Possible values : $true | $false. Default if omitted: $false + # Possible values : $true | $false (1 | 0 also accepted). Default if omitted: $false Enable = $false # Force : reboot even when the installer did not request one (exit code 0). Leave # $false to reboot strictly when a reboot was required (exit code 17022). - # Possible values : $true | $false. Default if omitted: $false + # Possible values : $true | $false (1 | 0 also accepted). Default if omitted: $false Force = $false # Schedule : OPTIONAL window restricting WHEN a reboot may happen (same shape as @@ -129,17 +129,17 @@ # MountContentDatabase : mount the databases listed in the generated ContentDB # inventory (typically for a SP2019 -> Subscription Edition migration). When either # this or UpgradeContentDatabase is $true, the inventory JSON is (re)built on run. - # Possible values : $true | $false. Default if omitted: $false + # Possible values : $true | $false (1 | 0 also accepted). Default if omitted: $false MountContentDatabase = $false # UpgradeContentDatabase : run Upgrade-SPContentDatabase on databases that NeedsUpgrade. - # Possible values : $true | $false. Default if omitted: $true + # Possible values : $true | $false (1 | 0 also accepted). Default if omitted: $true UpgradeContentDatabase = $true # --- Side-by-side patching (OPTIONAL block) -------------------------------------- SideBySideToken = @{ # Enable : turn EnableSideBySide on the web applications and copy side-by-side files. - # Possible values : $true | $false. Default if omitted: $false + # Possible values : $true | $false (1 | 0 also accepted). Default if omitted: $false Enable = $false # BuildVersion : the side-by-side token build. Leave empty to skip token config. @@ -155,7 +155,7 @@ # delegate the credential, so steps that need a second hop (config DB on SQL, or a # binaries file share) may fail unless Kerberos delegation (KCD/RBCD) is configured. # Leave off for secure, strict environments. - # Possible values : $true | $false. Default if omitted: $false + # Possible values : $true | $false (1 | 0 also accepted). Default if omitted: $false AllowFallback = $false } @@ -165,7 +165,7 @@ # parallel content-database sequences in visible PowerShell windows instead of hidden # scheduled tasks, so you can watch per-sequence progress live. Unattended/scheduled # runs always use scheduled tasks regardless of this setting. - # Possible values : $true | $false. Default if omitted: $false + # Possible values : $true | $false (1 | 0 also accepted). Default if omitted: $false InteractiveSequences = $false } } diff --git a/src/Modules/SPSUpdate.Common/Public/ConvertTo-SPSConfigBoolean.ps1 b/src/Modules/SPSUpdate.Common/Public/ConvertTo-SPSConfigBoolean.ps1 new file mode 100644 index 0000000..a6e13f8 --- /dev/null +++ b/src/Modules/SPSUpdate.Common/Public/ConvertTo-SPSConfigBoolean.ps1 @@ -0,0 +1,56 @@ +function ConvertTo-SPSConfigBoolean { + <# + .SYNOPSIS + Validates and normalizes a Boolean configuration value. + + .DESCRIPTION + ConvertTo-SPSConfigBoolean gives every Boolean switch in the SPSUpdate + configuration the same, predictable contract. It accepts a real Boolean + ($true / $false) or the integers 1 / 0, and returns the corresponding + [bool]. Any other value (an out-of-range integer, a string such as 'true' + or 'yes', $null, ...) throws a clear error naming the property, so a typo + in the psd1 fails fast instead of being silently coerced to $true. + + Only genuine integer types are accepted for the 1/0 form; a string like + '1' is rejected on purpose, to avoid the PowerShell footgun where + [bool]'false' evaluates to $true. + + .PARAMETER Value + The raw value read from the configuration for the property. + + .PARAMETER PropertyName + The dotted property path (for example 'Reboot.Enable') used in the error + message so the operator knows exactly which key to fix. + + .EXAMPLE + ConvertTo-SPSConfigBoolean -Value 1 -PropertyName 'Reboot.Enable' + + .EXAMPLE + ConvertTo-SPSConfigBoolean -Value $false -PropertyName 'Remoting.AllowFallback' + #> + [CmdletBinding()] + [OutputType([System.Boolean])] + param + ( + [Parameter(Mandatory = $true)] + [AllowNull()] + $Value, + + [Parameter(Mandatory = $true)] + [System.String] + $PropertyName + ) + + if ($Value -is [bool]) { + return $Value + } + + # Accept any integral type valued 0 or 1; the type guard keeps strings like '0'/'1' + # out (otherwise '0' -eq 0 would match via PowerShell string coercion). + $integralTypes = @([byte], [sbyte], [int16], [uint16], [int32], [uint32], [int64], [uint64]) + if (($null -ne $Value) -and ($Value.GetType() -in $integralTypes) -and ($Value -eq 0 -or $Value -eq 1)) { + return [System.Boolean][int64]$Value + } + + throw "Configuration property '$PropertyName' must be a Boolean (`$true/`$false) or 0/1, not '$Value'." +} diff --git a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 index 09e396a..42a0753 100644 --- a/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 +++ b/src/Modules/SPSUpdate.Common/SPSUpdate.Common.psd1 @@ -12,6 +12,7 @@ FunctionsToExport = @( 'Add-SPSScheduledTask' 'Add-SPSUpdateEvent' + 'ConvertTo-SPSConfigBoolean' 'ConvertTo-SPSPatchStatusLabel' 'ConvertTo-SPSRoleLabel' 'Copy-SPSSideBySideFilesRemote' diff --git a/src/SPSUpdate.ps1 b/src/SPSUpdate.ps1 index 682f5d9..8ea4484 100644 --- a/src/SPSUpdate.ps1 +++ b/src/SPSUpdate.ps1 @@ -150,17 +150,29 @@ function Get-SPSUpdateConfiguration { if (-not $config.Binaries.ContainsKey('ProductUpdate')) { $config.Binaries.ProductUpdate = $true } + else { + $config.Binaries.ProductUpdate = ConvertTo-SPSConfigBoolean -Value $config.Binaries.ProductUpdate -PropertyName 'Binaries.ProductUpdate' + } if (-not $config.Binaries.ContainsKey('ShutdownServices')) { $config.Binaries.ShutdownServices = $true } + else { + $config.Binaries.ShutdownServices = ConvertTo-SPSConfigBoolean -Value $config.Binaries.ShutdownServices -PropertyName 'Binaries.ShutdownServices' + } # Apply content-database defaults if (-not $config.ContainsKey('MountContentDatabase')) { $config.MountContentDatabase = $false } + else { + $config.MountContentDatabase = ConvertTo-SPSConfigBoolean -Value $config.MountContentDatabase -PropertyName 'MountContentDatabase' + } if (-not $config.ContainsKey('UpgradeContentDatabase')) { $config.UpgradeContentDatabase = $true } + else { + $config.UpgradeContentDatabase = ConvertTo-SPSConfigBoolean -Value $config.UpgradeContentDatabase -PropertyName 'UpgradeContentDatabase' + } # Normalize the SideBySideToken block and apply defaults if (-not $config.ContainsKey('SideBySideToken') -or $null -eq $config.SideBySideToken) { @@ -169,6 +181,9 @@ function Get-SPSUpdateConfiguration { if (-not $config.SideBySideToken.ContainsKey('Enable')) { $config.SideBySideToken.Enable = $false } + else { + $config.SideBySideToken.Enable = ConvertTo-SPSConfigBoolean -Value $config.SideBySideToken.Enable -PropertyName 'SideBySideToken.Enable' + } if (-not $config.SideBySideToken.ContainsKey('BuildVersion')) { $config.SideBySideToken.BuildVersion = '' } @@ -182,14 +197,14 @@ function Get-SPSUpdateConfiguration { if (-not $config.Reboot.ContainsKey('Enable')) { $config.Reboot.Enable = $false } - elseif ($config.Reboot.Enable -isnot [bool]) { - throw "Configuration property 'Reboot.Enable' must be a Boolean (`$true or `$false), not '$($config.Reboot.Enable)'." + else { + $config.Reboot.Enable = ConvertTo-SPSConfigBoolean -Value $config.Reboot.Enable -PropertyName 'Reboot.Enable' } if (-not $config.Reboot.ContainsKey('Force')) { $config.Reboot.Force = $false } - elseif ($config.Reboot.Force -isnot [bool]) { - throw "Configuration property 'Reboot.Force' must be a Boolean (`$true or `$false), not '$($config.Reboot.Force)'." + else { + $config.Reboot.Force = ConvertTo-SPSConfigBoolean -Value $config.Reboot.Force -PropertyName 'Reboot.Force' } # Remote cmdlets use CredSSP by default; Remoting.AllowFallback (opt-in, off) lets @@ -200,8 +215,8 @@ function Get-SPSUpdateConfiguration { if (-not $config.Remoting.ContainsKey('AllowFallback')) { $config.Remoting.AllowFallback = $false } - elseif ($config.Remoting.AllowFallback -isnot [bool]) { - throw "Configuration property 'Remoting.AllowFallback' must be a Boolean (`$true or `$false), not '$($config.Remoting.AllowFallback)'." + else { + $config.Remoting.AllowFallback = ConvertTo-SPSConfigBoolean -Value $config.Remoting.AllowFallback -PropertyName 'Remoting.AllowFallback' } # Execution.InteractiveSequences (opt-in, off): in an attended run, launch the parallel @@ -212,8 +227,8 @@ function Get-SPSUpdateConfiguration { if (-not $config.Execution.ContainsKey('InteractiveSequences')) { $config.Execution.InteractiveSequences = $false } - elseif ($config.Execution.InteractiveSequences -isnot [bool]) { - throw "Configuration property 'Execution.InteractiveSequences' must be a Boolean (`$true or `$false), not '$($config.Execution.InteractiveSequences)'." + else { + $config.Execution.InteractiveSequences = ConvertTo-SPSConfigBoolean -Value $config.Execution.InteractiveSequences -PropertyName 'Execution.InteractiveSequences' } # StatusStorePath is optional; empty string means "use the local Results\status folder". diff --git a/src/Test-SPSUpdateReadiness.ps1 b/src/Test-SPSUpdateReadiness.ps1 index 152d8c5..44e80c8 100644 --- a/src/Test-SPSUpdateReadiness.ps1 +++ b/src/Test-SPSUpdateReadiness.ps1 @@ -346,7 +346,14 @@ else { # Reboot enabled + UNC store: the SYSTEM boot task writes as the computer account, which we # cannot probe here - surface an explicit WARN about the required machine-account grant. $rebootEnabled = $false -try { $rebootEnabled = [bool]$cfg.Reboot.Enable } catch { $rebootEnabled = $false } +if ($null -ne $cfg -and $cfg.Contains('Reboot') -and $cfg.Reboot -and $cfg.Reboot.Contains('Enable')) { + try { + $rebootEnabled = ConvertTo-SPSConfigBoolean -Value $cfg.Reboot.Enable -PropertyName 'Reboot.Enable' + } + catch { + Add-CheckResult -Section 'Dashboard' -Name 'Reboot.Enable' -Status 'FAIL' -Detail $_.Exception.Message + } +} if ($rebootEnabled) { $storeForReboot = '' try { $storeForReboot = [string]$cfg.StatusStorePath } catch { $storeForReboot = '' } @@ -415,14 +422,14 @@ elseif ($null -ne $cfg -and $cfg.Contains('Domain') -and $cfg.Domain) { } # Remoting.AllowFallback is read raw here; default to the secure $false and reject a - # non-Boolean value (which Get-SPSUpdateConfiguration also rejects at run time). + # non-Boolean value the same way Get-SPSUpdateConfiguration does at run time. $allowFallback = $false if ($null -ne $cfg -and $cfg.Contains('Remoting') -and $cfg.Remoting -and $cfg.Remoting.Contains('AllowFallback')) { - if ($cfg.Remoting.AllowFallback -is [bool]) { - $allowFallback = $cfg.Remoting.AllowFallback + try { + $allowFallback = ConvertTo-SPSConfigBoolean -Value $cfg.Remoting.AllowFallback -PropertyName 'Remoting.AllowFallback' } - else { - Add-CheckResult -Section 'Network' -Name 'Remoting.AllowFallback' -Status 'FAIL' -Detail "Must be a Boolean (`$true/`$false); the run will reject '$($cfg.Remoting.AllowFallback)'" + catch { + Add-CheckResult -Section 'Network' -Name 'Remoting.AllowFallback' -Status 'FAIL' -Detail $_.Exception.Message } } diff --git a/tests/ConvertTo-SPSConfigBoolean.Tests.ps1 b/tests/ConvertTo-SPSConfigBoolean.Tests.ps1 new file mode 100644 index 0000000..0ff91ca --- /dev/null +++ b/tests/ConvertTo-SPSConfigBoolean.Tests.ps1 @@ -0,0 +1,81 @@ +# Behavioural tests for ConvertTo-SPSConfigBoolean: the shared validator/normalizer +# that gives every Boolean configuration switch the same $true/$false/1/0 contract. + +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 +} + +AfterAll { + Remove-Module -Name SPSUpdate.Common -Force -ErrorAction SilentlyContinue +} + +Describe 'ConvertTo-SPSConfigBoolean' { + Context 'Accepted values' { + It 'returns $true for a $true Boolean' { + ConvertTo-SPSConfigBoolean -Value $true -PropertyName 'Reboot.Enable' | Should -BeTrue + } + + It 'returns $false for a $false Boolean' { + ConvertTo-SPSConfigBoolean -Value $false -PropertyName 'Reboot.Enable' | Should -BeFalse + } + + It 'normalizes the integer 1 to $true' { + $result = ConvertTo-SPSConfigBoolean -Value 1 -PropertyName 'Reboot.Enable' + $result | Should -BeOfType [System.Boolean] + $result | Should -BeTrue + } + + It 'normalizes the integer 0 to $false' { + $result = ConvertTo-SPSConfigBoolean -Value 0 -PropertyName 'Reboot.Enable' + $result | Should -BeOfType [System.Boolean] + $result | Should -BeFalse + } + + It 'accepts other integral types valued 1 (byte, uint16, uint64)' { + ConvertTo-SPSConfigBoolean -Value ([byte]1) -PropertyName 'Reboot.Enable' | Should -BeTrue + ConvertTo-SPSConfigBoolean -Value ([uint16]1) -PropertyName 'Reboot.Enable' | Should -BeTrue + ConvertTo-SPSConfigBoolean -Value ([uint64]1) -PropertyName 'Reboot.Enable' | Should -BeTrue + } + + It 'accepts other integral types valued 0 (sbyte, uint32)' { + ConvertTo-SPSConfigBoolean -Value ([sbyte]0) -PropertyName 'Reboot.Enable' | Should -BeFalse + ConvertTo-SPSConfigBoolean -Value ([uint32]0) -PropertyName 'Reboot.Enable' | Should -BeFalse + } + } + + Context 'Rejected values' { + It 'throws for an out-of-range integer' { + { ConvertTo-SPSConfigBoolean -Value 2 -PropertyName 'Reboot.Enable' } | + Should -Throw -ExpectedMessage "*'Reboot.Enable'*" + } + + It 'rejects the string "1" (no string coercion)' { + { ConvertTo-SPSConfigBoolean -Value '1' -PropertyName 'Reboot.Force' } | + Should -Throw -ExpectedMessage "*'Reboot.Force'*" + } + + It 'rejects the string "0" (no string coercion)' { + { ConvertTo-SPSConfigBoolean -Value '0' -PropertyName 'Reboot.Force' } | Should -Throw + } + + It 'rejects an arbitrary truthy string such as "false"' { + { ConvertTo-SPSConfigBoolean -Value 'false' -PropertyName 'Remoting.AllowFallback' } | + Should -Throw -ExpectedMessage "*'Remoting.AllowFallback'*" + } + + It 'rejects "yes"' { + { ConvertTo-SPSConfigBoolean -Value 'yes' -PropertyName 'Execution.InteractiveSequences' } | Should -Throw + } + + It 'rejects $null' { + { ConvertTo-SPSConfigBoolean -Value $null -PropertyName 'Reboot.Enable' } | Should -Throw + } + + It 'names the offending property in the error message' { + { ConvertTo-SPSConfigBoolean -Value 7 -PropertyName 'Binaries.ProductUpdate' } | + Should -Throw -ExpectedMessage "*'Binaries.ProductUpdate'*" + } + } +} diff --git a/tests/SPSUpdate.Common.Tests.ps1 b/tests/SPSUpdate.Common.Tests.ps1 index 982b582..62af742 100644 --- a/tests/SPSUpdate.Common.Tests.ps1 +++ b/tests/SPSUpdate.Common.Tests.ps1 @@ -44,6 +44,7 @@ Describe 'SPSUpdate.Common module' { $expected = @( 'Add-SPSScheduledTask' 'Add-SPSUpdateEvent' + 'ConvertTo-SPSConfigBoolean' 'ConvertTo-SPSPatchStatusLabel' 'ConvertTo-SPSRoleLabel' 'Copy-SPSSideBySideFilesRemote' diff --git a/wiki/Configuration.md b/wiki/Configuration.md index be3877e..7dfd5dd 100644 --- a/wiki/Configuration.md +++ b/wiki/Configuration.md @@ -64,6 +64,13 @@ If an optional key is omitted, SPSUpdate applies a safe default: `Binaries.SetupFullPath` and `Binaries.SetupFileName` are required as soon as `ProductUpdate` is `$true`. +> **Boolean switches.** Every Boolean configuration switch — `Binaries.ProductUpdate`, +> `Binaries.ShutdownServices`, `UpgradeContentDatabase`, `MountContentDatabase`, +> `SideBySideToken.Enable`, `Reboot.Enable`, `Reboot.Force`, `Remoting.AllowFallback` +> and `Execution.InteractiveSequences` — accepts `$true` / `$false` **or** the integers +> `1` / `0` (`1` = `$true`, `0` = `$false`). Any other value (an out-of-range number, or a +> string such as `'yes'` or `'false'`) is rejected with a clear error naming the property. + > The previous JSON `StoredCredential` key has been renamed to `CredentialKey`, and the > configuration format moved from JSON to psd1. Runtime/output files (the ContentDB > inventory and logs) stay JSON by design.