scripts: vsql certificate script recovery paths from the second review
- A rollback record now says which restarts succeeded. A snapshot stays open while an instance is unrestored or its restart failed (or was declined), so -Rollback -RestartInstances retries exactly those instead of reporting done or stepping back to an older deployment. - Running dependents (SQL Server Agent) are written to dependents-<service>.json before the stop and removed once they run again, so a failed restart does not leave Agent stopped for good. - GetRegValue returns $null only for a value confirmed absent; a missing key or read error throws, so it is never snapshotted or verified as absent. - Instances come from the value names of Instance Names\SQL; the 'PS*' filter dropped names such as PSREPORTING. Tests for each, including the real registry operations on an HKCU test key. Pester green on PowerShell 7.6 (63) and Windows PowerShell 5.1 (62, 1 skipped). Co-Authored-By: Claude Opus 5.5 <[email protected]>
This commit is contained in:
@@ -84,6 +84,10 @@ BeforeAll {
|
||||
IcaclsFail = $false
|
||||
SetFails = @{} # regKey -> $true: Set-ItemProperty throws
|
||||
SetIgnored = @{} # regKey -> $true: the write does not stick
|
||||
ReadFails = @{} # regKey -> $true: reading throws (access denied)
|
||||
Dependents = @{ 'MSSQLSERVER' = @('SQLSERVERAGENT'); 'MSSQL$DEV' = @('SQLAgent$DEV') }
|
||||
Running = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
RestartFails = @{} # service -> $true: stops it and its dependents, then fails to start
|
||||
KeyFile = Join-Path $Work 'machinekey.bin'
|
||||
ShouldProcess = $null
|
||||
}
|
||||
@@ -97,7 +101,11 @@ BeforeAll {
|
||||
GetInstanceMap = { param($r) $fake.Instances }.GetNewClosure()
|
||||
GetService = { param($n) [void]$fake.Calls.Add("GetService $n"); $fake.Services[$n] }.GetNewClosure()
|
||||
TestRegKey = { param($k) $fake.Registry.ContainsKey($k) }.GetNewClosure()
|
||||
GetRegValue = { param($k, $n) $v = $fake.Registry[$k]; if ($v.ContainsKey($n)) { [string]$v[$n] } else { $null } }.GetNewClosure()
|
||||
GetRegValue = {
|
||||
param($k, $n)
|
||||
if ($fake.ReadFails[$k]) { throw "Requested registry access is not allowed: $k" }
|
||||
$v = $fake.Registry[$k]; if ($v.ContainsKey($n)) { [string]$v[$n] } else { $null }
|
||||
}.GetNewClosure()
|
||||
SetRegValue = {
|
||||
param($k, $n, $v)
|
||||
[void]$fake.Calls.Add("SetReg $k $n=$v")
|
||||
@@ -139,11 +147,21 @@ BeforeAll {
|
||||
GetKeyInfo = { param($c) $k = & $realKeyInfo $c; if ($k) { $k.Machine = $true }; $k }.GetNewClosure()
|
||||
KeyFilePath = { param($k) $fake.KeyFile }.GetNewClosure()
|
||||
TestFile = { param($f) Test-Path -LiteralPath $f -PathType Leaf }
|
||||
RestartService = { param($n) [void]$fake.Calls.Add("Restart $n"); @() }.GetNewClosure()
|
||||
GetRunningDependents = { param($n) @($fake.Dependents[$n] | Where-Object { $fake.Running.Contains($_) }) }.GetNewClosure()
|
||||
RestartService = {
|
||||
param($n)
|
||||
[void]$fake.Calls.Add("Restart $n")
|
||||
foreach ($d in $fake.Dependents[$n]) { [void]$fake.Running.Remove($d) }
|
||||
[void]$fake.Running.Remove($n)
|
||||
if ($fake.RestartFails[$n]) { throw "Service '$n' cannot be started" }
|
||||
[void]$fake.Running.Add($n)
|
||||
}.GetNewClosure()
|
||||
StartService = { param($n) [void]$fake.Calls.Add("Start $n"); [void]$fake.Running.Add($n) }.GetNewClosure()
|
||||
Now = { Get-Date }
|
||||
Log = { param($m) [void]$fake.Log.Add("$m") }.GetNewClosure()
|
||||
ShouldProcess = $null
|
||||
}
|
||||
foreach ($svc in 'MSSQLSERVER', 'MSSQL$DEV', 'SQLSERVERAGENT', 'SQLAgent$DEV') { [void]$fake.Running.Add($svc) }
|
||||
$fake.Provider = $p
|
||||
return $fake
|
||||
}
|
||||
@@ -547,6 +565,106 @@ Describe 'Finding 6: rollback restores each instance on its own and verifies' {
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Second review: recovery paths' {
|
||||
BeforeEach {
|
||||
$f = New-FakeHost (Join-Path $TestDrive ([guid]::NewGuid()))
|
||||
Add-ToStore $f $Good
|
||||
$f.Registry[$KeyDefault].Certificate = 'aaaa'; $f.Registry[$KeyDev].Certificate = 'bbbb'
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint | Out-Null
|
||||
$f.Calls.Clear()
|
||||
}
|
||||
|
||||
It 'a rollback whose restart failed is retried, not skipped and not stepped past' {
|
||||
$f.RestartFails['MSSQL$DEV'] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances } | Should -Throw '*Rollback incomplete*MSSQL$DEV*'
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
|
||||
$f.RestartFails.Clear()
|
||||
$f.Calls.Clear()
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances
|
||||
$r.Snapshot | Should -Not -BeNullOrEmpty
|
||||
$r.Restarted | Should -Be @('DEV')
|
||||
$f.Calls | Should -Contain 'Restart MSSQL$DEV'
|
||||
$f.Calls | Should -Not -Contain 'Restart MSSQLSERVER'
|
||||
$f.Registry[$KeyDev].Certificate | Should -Be 'bbbb'
|
||||
# Now complete: nothing left to do.
|
||||
(Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances).Snapshot | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'a declined restart under -Confirm also stays open' {
|
||||
$f.Provider.ShouldProcess = { param($t, $a) $a -notlike 'restart service*' -or $t -ne 'MSSQL$DEV' }
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances | Out-Null
|
||||
$f.Provider.ShouldProcess = $null
|
||||
$r = Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances
|
||||
$r.Restarted | Should -Be @('DEV')
|
||||
}
|
||||
It 'SQL Server Agent is started again after a failed restart is retried' {
|
||||
$f.RestartFails['MSSQL$DEV'] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances } | Should -Throw
|
||||
$f.Running.Contains('SQLAgent$DEV') | Should -BeFalse
|
||||
$depFile = Join-Path $f.Provider.WorkDir 'dependents-MSSQL_DEV.json'
|
||||
(Get-Content $depFile -Raw | ConvertFrom-Json).services | Should -Be @('SQLAgent$DEV')
|
||||
$f.RestartFails.Clear()
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback -RestartInstances | Out-Null
|
||||
$f.Calls | Should -Contain 'Start SQLAgent$DEV'
|
||||
$f.Running.Contains('SQLAgent$DEV') | Should -BeTrue
|
||||
Test-Path $depFile | Should -BeFalse
|
||||
}
|
||||
It 'a successful restart starts the agent that ran and leaves no dependents file' {
|
||||
Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good.Thumbprint -Instances MSSQLSERVER -RestartInstances | Out-Null
|
||||
$f.Calls | Should -Contain 'Start SQLSERVERAGENT'
|
||||
$f.Running.Contains('SQLSERVERAGENT') | Should -BeTrue
|
||||
@(Get-ChildItem $f.Provider.WorkDir -Filter 'dependents-*.json') | Should -BeNullOrEmpty
|
||||
}
|
||||
It 'a registry read failure stops a deployment before any snapshot or change' {
|
||||
Add-ToStore $f $Good2
|
||||
$before = @(Get-Snapshots $f).Count
|
||||
$f.ReadFails[$KeyDev] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Thumbprint $Good2.Thumbprint } | Should -Throw '*not allowed*'
|
||||
@($f.Calls | Where-Object { $_ -match '^(SetReg|RemoveReg)' }) | Should -BeNullOrEmpty
|
||||
@(Get-Snapshots $f).Count | Should -Be $before
|
||||
}
|
||||
It 'a registry read failure during rollback is a failure, never verified as absent' {
|
||||
$f.ReadFails[$KeyDev] = $true
|
||||
{ Invoke-VsqlCertificateDeployment -Provider $f.Provider -Rollback } | Should -Throw '*Rollback incomplete*DEV*not allowed*'
|
||||
$f.Log | Should -Not -Contain 'Restored DEV to bbbb (verified)'
|
||||
}
|
||||
}
|
||||
|
||||
Describe 'Real registry operations (HKCU test key)' {
|
||||
BeforeAll {
|
||||
$script:TestRoot = "HKCU:\Software\PS2-VSQL-CERT-TEST-$([guid]::NewGuid())"
|
||||
New-Item -Path "$TestRoot\Instance Names\SQL" -Force | Out-Null
|
||||
New-ItemProperty -Path "$TestRoot\Instance Names\SQL" -Name 'PSREPORTING' -Value 'MSSQL16.PSREPORTING' | Out-Null
|
||||
New-ItemProperty -Path "$TestRoot\Instance Names\SQL" -Name 'DEV' -Value 'MSSQL16.DEV' | Out-Null
|
||||
New-Item -Path "$TestRoot\Empty" -Force | Out-Null
|
||||
New-ItemProperty -Path "$TestRoot\Empty" -Name 'Certificate' -Value '' | Out-Null
|
||||
New-Item -Path "$TestRoot\Absent" -Force | Out-Null
|
||||
$script:Real = Get-VsqlDefaultProvider
|
||||
}
|
||||
AfterAll { Remove-Item -LiteralPath $TestRoot -Recurse -Force }
|
||||
|
||||
It 'lists every instance, including one whose name starts with PS' {
|
||||
$map = & $Real.GetInstanceMap $TestRoot
|
||||
@($map.Keys) | Should -Be @('PSREPORTING', 'DEV')
|
||||
$map['PSREPORTING'] | Should -Be 'MSSQL16.PSREPORTING'
|
||||
}
|
||||
It 'returns an empty map when no instance is registered' {
|
||||
(& $Real.GetInstanceMap "$TestRoot\Absent").Count | Should -Be 0
|
||||
}
|
||||
It 'tells an empty value from an absent one' {
|
||||
& $Real.GetRegValue "$TestRoot\Empty" 'Certificate' | Should -BeExactly ''
|
||||
$null -eq (& $Real.GetRegValue "$TestRoot\Absent" 'Certificate') | Should -BeTrue
|
||||
}
|
||||
It 'throws, rather than reporting absent, when the key cannot be read' {
|
||||
{ & $Real.GetRegValue "$TestRoot\Missing" 'Certificate' } | Should -Throw
|
||||
}
|
||||
It 'writes, reads back and removes through the real operations' {
|
||||
& $Real.SetRegValue "$TestRoot\Absent" 'Certificate' 'abcd'
|
||||
& $Real.GetRegValue "$TestRoot\Absent" 'Certificate' | Should -Be 'abcd'
|
||||
& $Real.RemoveRegValue "$TestRoot\Absent" 'Certificate'
|
||||
$null -eq (& $Real.GetRegValue "$TestRoot\Absent" 'Certificate') | Should -BeTrue
|
||||
}
|
||||
}
|
||||
|
||||
Describe '-WhatIf' {
|
||||
It 'changes nothing on the real ShouldProcess path' {
|
||||
$f = New-FakeHost (Join-Path $TestDrive 'whatif-real')
|
||||
|
||||
Reference in New Issue
Block a user