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:
+1
-1
@@ -229,7 +229,7 @@ Docker's default address pool on docker-1 holds fifteen /16 bridge networks (172
|
|||||||
|
|
||||||
Check the ERRORLOG line again. The production stack's own doctor run is Spencer's, with its own approval.
|
Check the ERRORLOG line again. The production stack's own doctor run is Spencer's, with its own approval.
|
||||||
|
|
||||||
6. Rollback. Each deployment saved `deploy-<time>.json` in `C:\ProgramData\PacketStation\vsql-cert`. `-Rollback` restores the newest one not yet rolled back. It restores each instance on its own, reads each value back and lists any failure. Preview it, then run it:
|
6. Rollback. Each deployment saved `deploy-<time>.json` in `C:\ProgramData\PacketStation\vsql-cert`. `-Rollback` restores the newest one not yet rolled back. It restores each instance on its own, reads each value back and lists any failure. If a restart fails, fix the cause and run the same command again: it retries only the instances not yet restored or restarted, and starts SQL Server Agent if it ran before the failed attempt. Preview it, then run it:
|
||||||
|
|
||||||
```powershell
|
```powershell
|
||||||
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Rollback -Instances DEV -WhatIf
|
powershell -ExecutionPolicy Bypass -File .\Request-VsqlServerCertificate.ps1 -Rollback -Instances DEV -WhatIf
|
||||||
|
|||||||
+4
-3
@@ -12,9 +12,10 @@ Run on a domain-joined PC. Exports every unexpired PESCO CA certificate from the
|
|||||||
Run elevated on the SQL Server host behind vsql, in Windows PowerShell 5.1 or PowerShell 7. Requests a TLS certificate from PESCO-PSYLOCKE-CA with certreq the way SQL Server requires it (legacy Microsoft RSA SChannel Cryptographic Provider, KeySpec AT_KEYEXCHANGE, RSA 2048, SHA-256, Server Authentication, key usage 0xA0, SANs vsql.pesco.com, vsql and the host FQDN and short name). It installs it and validates it before anything is bound. The validation checks the key provider and KeySpec from the certificate's own key property, the key size, the signature, the dates, the EKU, and the SAN DNS names as whole names. It grants only the selected instances' service accounts read on the private key, then binds the thumbprint and reads it back.
|
Run elevated on the SQL Server host behind vsql, in Windows PowerShell 5.1 or PowerShell 7. Requests a TLS certificate from PESCO-PSYLOCKE-CA with certreq the way SQL Server requires it (legacy Microsoft RSA SChannel Cryptographic Provider, KeySpec AT_KEYEXCHANGE, RSA 2048, SHA-256, Server Authentication, key usage 0xA0, SANs vsql.pesco.com, vsql and the host FQDN and short name). It installs it and validates it before anything is bound. The validation checks the key provider and KeySpec from the certificate's own key property, the key size, the signature, the dates, the EKU, and the SAN DNS names as whole names. It grants only the selected instances' service accounts read on the private key, then binds the thumbprint and reads it back.
|
||||||
|
|
||||||
- `-WhatIf` previews every step and changes nothing. `-Confirm` asks before each change. Declining enrollment or a key grant cancels the run with nothing bound (exit code 2).
|
- `-WhatIf` previews every step and changes nothing. `-Confirm` asks before each change. Declining enrollment or a key grant cancels the run with nothing bound (exit code 2).
|
||||||
- `-CerFile` continues a request that was pending. `-Thumbprint` binds a certificate already installed. `-Instances` limits the run (`DEV`, `MSSQLSERVER`). `-RestartInstances` restarts them and starts SQL Server Agent again if it ran.
|
- `-CerFile` continues a request that was pending. `-Thumbprint` binds a certificate already installed. `-Instances` limits the run (`DEV`, `MSSQLSERVER`). `-RestartInstances` restarts them and starts SQL Server Agent again if it ran. The running dependents are written to `dependents-<service>.json` before the stop and the file is removed once they run again, so after a failed restart the next restart of that service starts them too.
|
||||||
- Each deployment writes a new `deploy-<time>.json` in `C:\ProgramData\PacketStation\vsql-cert` with the values it replaced. Instances already bound to the thumbprint are left alone and get no snapshot, so a rerun never hides the useful one. A pending request writes `request-<time>.json` with its request id.
|
- Each deployment writes a new `deploy-<time>.json` in `C:\ProgramData\PacketStation\vsql-cert` with the values it replaced. Instances already bound to the thumbprint are left alone and get no snapshot, so a rerun never hides the useful one. A pending request writes `request-<time>.json` with its request id. A registry value that cannot be read stops the run; only a value confirmed absent is saved as absent.
|
||||||
- `-Rollback` restores the newest snapshot not yet rolled back, each instance on its own, verified by reading back, and records it in `rollback-<time>.json`. `-Snapshot` picks another. Failures are listed and the run exits with an error.
|
- `-Rollback` restores the newest snapshot not yet rolled back, each instance on its own, verified by reading back, and records it in `rollback-<time>.json` with which restarts succeeded. A snapshot stays open while an instance is unrestored or its restart failed, so `-Rollback -RestartInstances` again retries exactly those. `-Snapshot` picks another. Failures are listed and the run exits with an error.
|
||||||
|
- Instances are read from the value names of `Instance Names\SQL`, so a name starting with `PS` (for example `PSREPORTING`) is found.
|
||||||
|
|
||||||
Tests: `scripts/tests/Request-VsqlServerCertificate.Tests.ps1` (Pester 5) fakes the host (instances, services, registry, machine store, certreq, icacls) and uses real throwaway certificates in `Cert:\CurrentUser\My`. Run it on both PowerShell versions:
|
Tests: `scripts/tests/Request-VsqlServerCertificate.Tests.ps1` (Pester 5) fakes the host (instances, services, registry, machine store, certreq, icacls) and uses real throwaway certificates in `Cert:\CurrentUser\My`. Run it on both PowerShell versions:
|
||||||
|
|
||||||
|
|||||||
@@ -329,18 +329,26 @@ function Get-VsqlDefaultProvider {
|
|||||||
InstanceRoot = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
|
InstanceRoot = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
|
||||||
IsAdmin = { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) }
|
IsAdmin = { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) }
|
||||||
GetInstanceMap = {
|
GetInstanceMap = {
|
||||||
|
# The key's own value names. Get-ItemProperty mixes in PSPath and friends, and
|
||||||
|
# filtering those by 'PS*' would drop a real instance such as PSREPORTING.
|
||||||
param($Root)
|
param($Root)
|
||||||
$p = Get-ItemProperty -LiteralPath "$Root\Instance Names\SQL" -ErrorAction SilentlyContinue
|
$path = "$Root\Instance Names\SQL"
|
||||||
$map = [ordered]@{}
|
$map = [ordered]@{}
|
||||||
if ($p) { foreach ($x in $p.PSObject.Properties) { if ($x.Name -notlike 'PS*') { $map[$x.Name] = [string]$x.Value } } }
|
if (Test-Path -LiteralPath $path) {
|
||||||
|
$key = Get-Item -LiteralPath $path -ErrorAction Stop
|
||||||
|
foreach ($n in $key.GetValueNames()) { if ($n) { $map[$n] = [string]$key.GetValue($n) } }
|
||||||
|
}
|
||||||
$map
|
$map
|
||||||
}
|
}
|
||||||
GetService = { param($Name) Get-CimInstance Win32_Service -Filter (Get-ServiceFilter $Name) -ErrorAction Stop }
|
GetService = { param($Name) Get-CimInstance Win32_Service -Filter (Get-ServiceFilter $Name) -ErrorAction Stop }
|
||||||
TestRegKey = { param($Key) Test-Path -LiteralPath $Key }
|
TestRegKey = { param($Key) Test-Path -LiteralPath $Key }
|
||||||
GetRegValue = {
|
GetRegValue = {
|
||||||
|
# $null only for a value confirmed absent. A missing key or a read error throws, so it
|
||||||
|
# is never saved in a snapshot, or verified after a restore, as "absent".
|
||||||
param($Key, $Name)
|
param($Key, $Name)
|
||||||
$p = Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction SilentlyContinue
|
$item = Get-Item -LiteralPath $Key -ErrorAction Stop
|
||||||
if ($p) { [string]$p.$Name } else { $null }
|
if (@($item.GetValueNames()) -notcontains $Name) { return $null }
|
||||||
|
[string]$item.GetValue($Name, $null, [Microsoft.Win32.RegistryValueOptions]::DoNotExpandEnvironmentNames)
|
||||||
}
|
}
|
||||||
SetRegValue = { param($Key, $Name, $Value) Set-ItemProperty -LiteralPath $Key -Name $Name -Value $Value -ErrorAction Stop -Confirm:$false }
|
SetRegValue = { param($Key, $Name, $Value) Set-ItemProperty -LiteralPath $Key -Name $Name -Value $Value -ErrorAction Stop -Confirm:$false }
|
||||||
RemoveRegValue = { param($Key, $Name) Remove-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction Stop -Confirm:$false }
|
RemoveRegValue = { param($Key, $Name) Remove-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction Stop -Confirm:$false }
|
||||||
@@ -352,13 +360,9 @@ function Get-VsqlDefaultProvider {
|
|||||||
GetKeyInfo = { param($Cert) Get-CertificateKeyInfo $Cert }
|
GetKeyInfo = { param($Cert) Get-CertificateKeyInfo $Cert }
|
||||||
KeyFilePath = { param($KeyInfo) Join-Path $env:ProgramData "Microsoft\Crypto\RSA\MachineKeys\$($KeyInfo.UniqueName)" }
|
KeyFilePath = { param($KeyInfo) Join-Path $env:ProgramData "Microsoft\Crypto\RSA\MachineKeys\$($KeyInfo.UniqueName)" }
|
||||||
TestFile = { param($Path) Test-Path -LiteralPath $Path -PathType Leaf }
|
TestFile = { param($Path) Test-Path -LiteralPath $Path -PathType Leaf }
|
||||||
RestartService = {
|
GetRunningDependents = { param($Name) @((Get-Service -Name $Name -ErrorAction Stop).DependentServices | Where-Object { $_.Status -eq 'Running' } | ForEach-Object { $_.Name }) }
|
||||||
param($Name)
|
RestartService = { param($Name) Restart-Service -Name $Name -Force -ErrorAction Stop -Confirm:$false }
|
||||||
$running = @((Get-Service -Name $Name).DependentServices | Where-Object { $_.Status -eq 'Running' })
|
StartService = { param($Name) Start-Service -Name $Name -ErrorAction Stop -Confirm:$false }
|
||||||
Restart-Service -Name $Name -Force -ErrorAction Stop -Confirm:$false
|
|
||||||
foreach ($d in $running) { Start-Service -Name $d.Name -ErrorAction Stop -Confirm:$false }
|
|
||||||
@($running | ForEach-Object { $_.Name })
|
|
||||||
}
|
|
||||||
Now = { Get-Date }
|
Now = { Get-Date }
|
||||||
Log = { param($Message) Write-Host $Message }
|
Log = { param($Message) Write-Host $Message }
|
||||||
ShouldProcess = $null # $null: the cmdlet's own ShouldProcess (-WhatIf, -Confirm)
|
ShouldProcess = $null # $null: the cmdlet's own ShouldProcess (-WhatIf, -Confirm)
|
||||||
@@ -421,19 +425,37 @@ function Format-Value($Value) {
|
|||||||
return $Value
|
return $Value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Get-DependentsFile($P, [string]$Service) {
|
||||||
|
return Join-Path $P.WorkDir ('dependents-' + ($Service -replace '[^A-Za-z0-9]', '_') + '.json')
|
||||||
|
}
|
||||||
|
|
||||||
function Invoke-InstanceRestart($P, $Confirm, [object[]]$Targets) {
|
function Invoke-InstanceRestart($P, $Confirm, [object[]]$Targets) {
|
||||||
|
# Restart-Service -Force stops dependents (SQL Server Agent) and does not start them again.
|
||||||
|
# The running ones are written to dependents-<service>.json before the stop, and the file is
|
||||||
|
# removed only once they run again. A failed restart therefore does not lose them: the next
|
||||||
|
# restart of that service, by a rerun or a rollback, starts them too.
|
||||||
$failed = New-Object System.Collections.Generic.List[string]
|
$failed = New-Object System.Collections.Generic.List[string]
|
||||||
$restarted = New-Object System.Collections.Generic.List[string]
|
$restarted = New-Object System.Collections.Generic.List[string]
|
||||||
foreach ($t in $Targets) {
|
foreach ($t in $Targets) {
|
||||||
if (& $Confirm $t.Service 'restart service (and start its running dependents again)') {
|
if (-not (& $Confirm $t.Service 'restart service (and start its running dependents again)')) { continue }
|
||||||
try {
|
try {
|
||||||
$deps = @(& $P.RestartService $t.Service)
|
$file = Get-DependentsFile $P $t.Service
|
||||||
|
$saved = @()
|
||||||
|
if (Test-Path -LiteralPath $file) { $saved = @((Get-Content -LiteralPath $file -Raw | ConvertFrom-Json).services) }
|
||||||
|
$deps = @(Get-UniqueNames (@($saved) + @(& $P.GetRunningDependents $t.Service)))
|
||||||
|
if ($deps.Count) {
|
||||||
|
if (-not (Test-Path -LiteralPath $P.WorkDir)) { New-Item -ItemType Directory -Force -Path $P.WorkDir -Confirm:$false | Out-Null }
|
||||||
|
$json = ConvertTo-Json -InputObject ([ordered]@{ service = $t.Service; services = $deps; at = (& $P.Now).ToString('s') })
|
||||||
|
[System.IO.File]::WriteAllText($file, $json, (New-Object System.Text.UTF8Encoding $false))
|
||||||
|
}
|
||||||
|
& $P.RestartService $t.Service
|
||||||
|
foreach ($d in $deps) { & $P.StartService $d }
|
||||||
|
if (Test-Path -LiteralPath $file) { Remove-Item -LiteralPath $file -Confirm:$false }
|
||||||
$restarted.Add($t.Name)
|
$restarted.Add($t.Name)
|
||||||
& $P.Log "Restarted $($t.Service)$(if ($deps) { "; started again: $($deps -join ', ')" })"
|
& $P.Log "Restarted $($t.Service)$(if ($deps.Count) { "; started again: $($deps -join ', ')" })"
|
||||||
}
|
}
|
||||||
catch { $failed.Add("$($t.Service): $($_.Exception.Message)") }
|
catch { $failed.Add("$($t.Service): $($_.Exception.Message)") }
|
||||||
}
|
}
|
||||||
}
|
|
||||||
return [pscustomobject]@{ Restarted = $restarted.ToArray(); Failed = $failed.ToArray() }
|
return [pscustomobject]@{ Restarted = $restarted.ToArray(); Failed = $failed.ToArray() }
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -447,20 +469,31 @@ function New-RecordPath($P, [string]$Prefix) {
|
|||||||
throw "No free $Prefix record name in $($P.WorkDir)."
|
throw "No free $Prefix record name in $($P.WorkDir)."
|
||||||
}
|
}
|
||||||
|
|
||||||
function Get-RolledBackInstances($P) {
|
function Get-RollbackState($P) {
|
||||||
# Snapshot file name -> instance names already restored from it, from the rollback records.
|
# Snapshot file name -> what the rollback records say: instances whose registry value was
|
||||||
$done = @{}
|
# restored and verified, and instances whose restart was asked for but has not succeeded.
|
||||||
foreach ($f in @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'rollback-*.json' -File -ErrorAction SilentlyContinue)) {
|
# Records are read in time order, so a later successful restart clears an earlier failure.
|
||||||
|
$state = @{}
|
||||||
|
foreach ($f in @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'rollback-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name)) {
|
||||||
$r = Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json
|
$r = Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json
|
||||||
if (-not $done.ContainsKey($r.snapshot)) { $done[$r.snapshot] = @() }
|
if (-not $state.ContainsKey($r.snapshot)) {
|
||||||
$done[$r.snapshot] += @($r.instances)
|
$state[$r.snapshot] = [pscustomobject]@{
|
||||||
|
Restored = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||||
|
RestartPending = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||||
}
|
}
|
||||||
return $done
|
}
|
||||||
|
$s = $state[$r.snapshot]
|
||||||
|
foreach ($n in @($r.instances)) { if ($n) { [void]$s.Restored.Add($n) } }
|
||||||
|
foreach ($n in @($r.restarted)) { if ($n) { [void]$s.RestartPending.Remove($n) } }
|
||||||
|
foreach ($n in @($r.restartPending)) { if ($n) { [void]$s.RestartPending.Add($n) } }
|
||||||
|
}
|
||||||
|
return $state
|
||||||
}
|
}
|
||||||
|
|
||||||
function Invoke-VsqlRollback($P, $Confirm, [bool]$WhatIf, [string[]]$Instances, [string]$Snapshot, [bool]$RestartInstances) {
|
function Invoke-VsqlRollback($P, $Confirm, [bool]$WhatIf, [string[]]$Instances, [string]$Snapshot, [bool]$RestartInstances) {
|
||||||
# Default: the newest snapshot with instances not yet restored, so each -Rollback steps back
|
# Default: the newest snapshot with an instance not yet restored, or restored with a restart
|
||||||
# one deployment and a second run never re-applies a newer snapshot. -Snapshot picks one.
|
# that did not succeed. Each -Rollback steps back one deployment, a retry finishes the one
|
||||||
|
# that failed, and a later run never re-applies a newer snapshot. -Snapshot picks one.
|
||||||
$files = @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'deploy-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name -Descending)
|
$files = @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'deploy-*.json' -File -ErrorAction SilentlyContinue | Sort-Object Name -Descending)
|
||||||
if (-not $files) { throw "No deployment snapshots in $($P.WorkDir)." }
|
if (-not $files) { throw "No deployment snapshots in $($P.WorkDir)." }
|
||||||
$load = { param($f) Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json }
|
$load = { param($f) Get-Content -LiteralPath $f.FullName -Raw | ConvertFrom-Json }
|
||||||
@@ -479,11 +512,11 @@ function Invoke-VsqlRollback($P, $Confirm, [bool]$WhatIf, [string[]]$Instances,
|
|||||||
$entries = @(& $pick $chosen.Data)
|
$entries = @(& $pick $chosen.Data)
|
||||||
}
|
}
|
||||||
else {
|
else {
|
||||||
$done = Get-RolledBackInstances $P
|
$state = Get-RollbackState $P
|
||||||
foreach ($f in $files) {
|
foreach ($f in $files) {
|
||||||
$s = & $load $f
|
$s = & $load $f
|
||||||
$restoredBefore = if ($done.ContainsKey($f.Name)) { @($done[$f.Name]) } else { @() }
|
$st = $state[$f.Name]
|
||||||
$open = @(& $pick $s | Where-Object { $restoredBefore -notcontains $_.name })
|
$open = @(& $pick $s | Where-Object { -not $st -or -not $st.Restored.Contains($_.name) })
|
||||||
if ($open) { $chosen = [pscustomobject]@{ File = $f; Data = $s }; $entries = $open; break }
|
if ($open) { $chosen = [pscustomobject]@{ File = $f; Data = $s }; $entries = $open; break }
|
||||||
}
|
}
|
||||||
if (-not $chosen) {
|
if (-not $chosen) {
|
||||||
@@ -508,13 +541,26 @@ function Invoke-VsqlRollback($P, $Confirm, [bool]$WhatIf, [string[]]$Instances,
|
|||||||
& $P.Log "Restored $($e.name) to $(Format-Value $prev) (verified)"
|
& $P.Log "Restored $($e.name) to $(Format-Value $prev) (verified)"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if ($restored.Count) {
|
|
||||||
Write-NewJsonFile (New-RecordPath $P 'rollback') ([ordered]@{ at = (& $P.Now).ToString('s'); snapshot = $chosen.File.Name; instances = @($restored | ForEach-Object { $_.Name }) })
|
|
||||||
}
|
|
||||||
$restart = [pscustomobject]@{ Restarted = @(); Failed = @() }
|
$restart = [pscustomobject]@{ Restarted = @(); Failed = @() }
|
||||||
if ($restored.Count) {
|
if ($restored.Count) {
|
||||||
if ($RestartInstances) { $restart = Invoke-InstanceRestart $P $Confirm $restored.ToArray() }
|
$names = @($restored | ForEach-Object { $_.Name })
|
||||||
|
if ($RestartInstances) {
|
||||||
|
try { $restart = Invoke-InstanceRestart $P $Confirm $restored.ToArray() }
|
||||||
|
finally {
|
||||||
|
# Written after the restarts and even if they throw. A restored instance that was not
|
||||||
|
# restarted stays open, so the next -Rollback -RestartInstances retries it.
|
||||||
|
$done = @($restart.Restarted)
|
||||||
|
Write-NewJsonFile (New-RecordPath $P 'rollback') ([ordered]@{
|
||||||
|
at = (& $P.Now).ToString('s')
|
||||||
|
snapshot = $chosen.File.Name
|
||||||
|
instances = $names
|
||||||
|
restarted = $done
|
||||||
|
restartPending = @($names | Where-Object { $done -notcontains $_ })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
else {
|
else {
|
||||||
|
Write-NewJsonFile (New-RecordPath $P 'rollback') ([ordered]@{ at = (& $P.Now).ToString('s'); snapshot = $chosen.File.Name; instances = $names; restarted = @(); restartPending = @() })
|
||||||
& $P.Log 'Restart the restored instances for the rollback to take effect:'
|
& $P.Log 'Restart the restored instances for the rollback to take effect:'
|
||||||
foreach ($r in $restored) { & $P.Log " Restart-Service -Name '$($r.Service)' -Force" }
|
foreach ($r in $restored) { & $P.Log " Restart-Service -Name '$($r.Service)' -Force" }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -84,6 +84,10 @@ BeforeAll {
|
|||||||
IcaclsFail = $false
|
IcaclsFail = $false
|
||||||
SetFails = @{} # regKey -> $true: Set-ItemProperty throws
|
SetFails = @{} # regKey -> $true: Set-ItemProperty throws
|
||||||
SetIgnored = @{} # regKey -> $true: the write does not stick
|
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'
|
KeyFile = Join-Path $Work 'machinekey.bin'
|
||||||
ShouldProcess = $null
|
ShouldProcess = $null
|
||||||
}
|
}
|
||||||
@@ -97,7 +101,11 @@ BeforeAll {
|
|||||||
GetInstanceMap = { param($r) $fake.Instances }.GetNewClosure()
|
GetInstanceMap = { param($r) $fake.Instances }.GetNewClosure()
|
||||||
GetService = { param($n) [void]$fake.Calls.Add("GetService $n"); $fake.Services[$n] }.GetNewClosure()
|
GetService = { param($n) [void]$fake.Calls.Add("GetService $n"); $fake.Services[$n] }.GetNewClosure()
|
||||||
TestRegKey = { param($k) $fake.Registry.ContainsKey($k) }.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 = {
|
SetRegValue = {
|
||||||
param($k, $n, $v)
|
param($k, $n, $v)
|
||||||
[void]$fake.Calls.Add("SetReg $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()
|
GetKeyInfo = { param($c) $k = & $realKeyInfo $c; if ($k) { $k.Machine = $true }; $k }.GetNewClosure()
|
||||||
KeyFilePath = { param($k) $fake.KeyFile }.GetNewClosure()
|
KeyFilePath = { param($k) $fake.KeyFile }.GetNewClosure()
|
||||||
TestFile = { param($f) Test-Path -LiteralPath $f -PathType Leaf }
|
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 }
|
Now = { Get-Date }
|
||||||
Log = { param($m) [void]$fake.Log.Add("$m") }.GetNewClosure()
|
Log = { param($m) [void]$fake.Log.Add("$m") }.GetNewClosure()
|
||||||
ShouldProcess = $null
|
ShouldProcess = $null
|
||||||
}
|
}
|
||||||
|
foreach ($svc in 'MSSQLSERVER', 'MSSQL$DEV', 'SQLSERVERAGENT', 'SQLAgent$DEV') { [void]$fake.Running.Add($svc) }
|
||||||
$fake.Provider = $p
|
$fake.Provider = $p
|
||||||
return $fake
|
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' {
|
Describe '-WhatIf' {
|
||||||
It 'changes nothing on the real ShouldProcess path' {
|
It 'changes nothing on the real ShouldProcess path' {
|
||||||
$f = New-FakeHost (Join-Path $TestDrive 'whatif-real')
|
$f = New-FakeHost (Join-Path $TestDrive 'whatif-real')
|
||||||
|
|||||||
Reference in New Issue
Block a user