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:
@@ -329,18 +329,26 @@ function Get-VsqlDefaultProvider {
|
||||
InstanceRoot = 'HKLM:\SOFTWARE\Microsoft\Microsoft SQL Server'
|
||||
IsAdmin = { ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator) }
|
||||
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)
|
||||
$p = Get-ItemProperty -LiteralPath "$Root\Instance Names\SQL" -ErrorAction SilentlyContinue
|
||||
$path = "$Root\Instance Names\SQL"
|
||||
$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
|
||||
}
|
||||
GetService = { param($Name) Get-CimInstance Win32_Service -Filter (Get-ServiceFilter $Name) -ErrorAction Stop }
|
||||
TestRegKey = { param($Key) Test-Path -LiteralPath $Key }
|
||||
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)
|
||||
$p = Get-ItemProperty -LiteralPath $Key -Name $Name -ErrorAction SilentlyContinue
|
||||
if ($p) { [string]$p.$Name } else { $null }
|
||||
$item = Get-Item -LiteralPath $Key -ErrorAction Stop
|
||||
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 }
|
||||
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 }
|
||||
KeyFilePath = { param($KeyInfo) Join-Path $env:ProgramData "Microsoft\Crypto\RSA\MachineKeys\$($KeyInfo.UniqueName)" }
|
||||
TestFile = { param($Path) Test-Path -LiteralPath $Path -PathType Leaf }
|
||||
RestartService = {
|
||||
param($Name)
|
||||
$running = @((Get-Service -Name $Name).DependentServices | Where-Object { $_.Status -eq 'Running' })
|
||||
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 })
|
||||
}
|
||||
GetRunningDependents = { param($Name) @((Get-Service -Name $Name -ErrorAction Stop).DependentServices | Where-Object { $_.Status -eq 'Running' } | ForEach-Object { $_.Name }) }
|
||||
RestartService = { param($Name) Restart-Service -Name $Name -Force -ErrorAction Stop -Confirm:$false }
|
||||
StartService = { param($Name) Start-Service -Name $Name -ErrorAction Stop -Confirm:$false }
|
||||
Now = { Get-Date }
|
||||
Log = { param($Message) Write-Host $Message }
|
||||
ShouldProcess = $null # $null: the cmdlet's own ShouldProcess (-WhatIf, -Confirm)
|
||||
@@ -421,18 +425,36 @@ function Format-Value($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) {
|
||||
# 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]
|
||||
$restarted = New-Object System.Collections.Generic.List[string]
|
||||
foreach ($t in $Targets) {
|
||||
if (& $Confirm $t.Service 'restart service (and start its running dependents again)') {
|
||||
try {
|
||||
$deps = @(& $P.RestartService $t.Service)
|
||||
$restarted.Add($t.Name)
|
||||
& $P.Log "Restarted $($t.Service)$(if ($deps) { "; started again: $($deps -join ', ')" })"
|
||||
if (-not (& $Confirm $t.Service 'restart service (and start its running dependents again)')) { continue }
|
||||
try {
|
||||
$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))
|
||||
}
|
||||
catch { $failed.Add("$($t.Service): $($_.Exception.Message)") }
|
||||
& $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)
|
||||
& $P.Log "Restarted $($t.Service)$(if ($deps.Count) { "; started again: $($deps -join ', ')" })"
|
||||
}
|
||||
catch { $failed.Add("$($t.Service): $($_.Exception.Message)") }
|
||||
}
|
||||
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)."
|
||||
}
|
||||
|
||||
function Get-RolledBackInstances($P) {
|
||||
# Snapshot file name -> instance names already restored from it, from the rollback records.
|
||||
$done = @{}
|
||||
foreach ($f in @(Get-ChildItem -LiteralPath $P.WorkDir -Filter 'rollback-*.json' -File -ErrorAction SilentlyContinue)) {
|
||||
function Get-RollbackState($P) {
|
||||
# Snapshot file name -> what the rollback records say: instances whose registry value was
|
||||
# restored and verified, and instances whose restart was asked for but has not succeeded.
|
||||
# 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
|
||||
if (-not $done.ContainsKey($r.snapshot)) { $done[$r.snapshot] = @() }
|
||||
$done[$r.snapshot] += @($r.instances)
|
||||
if (-not $state.ContainsKey($r.snapshot)) {
|
||||
$state[$r.snapshot] = [pscustomobject]@{
|
||||
Restored = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
RestartPending = New-Object 'System.Collections.Generic.HashSet[string]' ([StringComparer]::OrdinalIgnoreCase)
|
||||
}
|
||||
}
|
||||
$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 $done
|
||||
return $state
|
||||
}
|
||||
|
||||
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
|
||||
# one deployment and a second run never re-applies a newer snapshot. -Snapshot picks one.
|
||||
# Default: the newest snapshot with an instance not yet restored, or restored with a restart
|
||||
# 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)
|
||||
if (-not $files) { throw "No deployment snapshots in $($P.WorkDir)." }
|
||||
$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)
|
||||
}
|
||||
else {
|
||||
$done = Get-RolledBackInstances $P
|
||||
$state = Get-RollbackState $P
|
||||
foreach ($f in $files) {
|
||||
$s = & $load $f
|
||||
$restoredBefore = if ($done.ContainsKey($f.Name)) { @($done[$f.Name]) } else { @() }
|
||||
$open = @(& $pick $s | Where-Object { $restoredBefore -notcontains $_.name })
|
||||
$st = $state[$f.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 (-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)"
|
||||
}
|
||||
}
|
||||
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 = @() }
|
||||
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 {
|
||||
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:'
|
||||
foreach ($r in $restored) { & $P.Log " Restart-Service -Name '$($r.Service)' -Force" }
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user