feat(recovery): add controlled VMware autostart apply
This commit is contained in:
@@ -0,0 +1,36 @@
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[3]
|
||||
SCRIPT = (
|
||||
ROOT
|
||||
/ "scripts"
|
||||
/ "reboot-recovery"
|
||||
/ "windows99-vmware-autostart-controlled-apply.ps1"
|
||||
)
|
||||
|
||||
|
||||
def test_controlled_apply_has_check_apply_verify_and_rollback_contract() -> None:
|
||||
text = SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
for mode in ("DryRun", "Apply", "Verify"):
|
||||
assert f'Invoke-AutostartMode -Mode {mode}' in text
|
||||
assert "Export-ScheduledTask" in text
|
||||
assert "Register-ScheduledTask -TaskName $TaskName -Xml $taskXml -Force" in text
|
||||
assert "Restore-PolicySnapshot" in text
|
||||
assert "Restore-ServiceStartModes" in text
|
||||
assert 'status = "failed_rolled_back"' in text
|
||||
assert 'status = "completed"' in text
|
||||
|
||||
|
||||
def test_controlled_apply_proves_no_reboot_or_vm_power_change() -> None:
|
||||
text = SCRIPT.read_text(encoding="utf-8")
|
||||
|
||||
assert "preVmxProcessCount" in text
|
||||
assert "postVmxProcessCount" in text
|
||||
assert "processCountUnchanged" in text
|
||||
assert 'hostRebootPerformed = $false' in text
|
||||
assert 'vmPowerChangePerformed = $false' in text
|
||||
assert 'secretReadPerformed = $false' in text
|
||||
assert "runningVmRows" in text
|
||||
assert "VMWARE_AUTOSTART_VERIFY_READY=1" in text
|
||||
@@ -0,0 +1,244 @@
|
||||
param(
|
||||
[string]$SourceScript = (Join-Path $PSScriptRoot "windows99-vmware-autostart.ps1"),
|
||||
[string]$AgentRoot = "C:\Wooo\Agent99",
|
||||
[string]$SourceRevision = "unknown",
|
||||
[string]$TaskName = "AWOOOI-Start-VMware-VMs",
|
||||
[string]$StartScript = "C:\ProgramData\AWOOOI\Start-AWOOOI-VMs.ps1",
|
||||
[int]$ExpectedVmCount = 5
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$PolicyPath = "HKLM:\SOFTWARE\Policies\Microsoft\Windows\WindowsUpdate\AU"
|
||||
$PolicyNames = @(
|
||||
"NoAutoRebootWithLoggedOnUsers",
|
||||
"AlwaysAutoRebootAtScheduledTime",
|
||||
"AUOptions",
|
||||
"ScheduledInstallDay",
|
||||
"ScheduledInstallTime",
|
||||
"AUPowerManagement"
|
||||
)
|
||||
$ServiceNames = @("VMAuthdService", "VMnetDHCP")
|
||||
$PowerShellExe = Join-Path $env:SystemRoot "System32\WindowsPowerShell\v1.0\powershell.exe"
|
||||
$Stamp = Get-Date -Format "yyyyMMdd-HHmmss"
|
||||
$TraceId = "windows99-vmware-autostart-$Stamp"
|
||||
$BackupDir = Join-Path $AgentRoot "deploy\vmware-autostart-backup-$Stamp"
|
||||
$EvidencePath = Join-Path $AgentRoot "evidence\windows99-vmware-autostart-apply-$Stamp.json"
|
||||
|
||||
function Invoke-AutostartMode {
|
||||
param([ValidateSet("DryRun", "Apply", "Verify")][string]$Mode)
|
||||
|
||||
$output = @(& $PowerShellExe `
|
||||
-NoProfile `
|
||||
-NonInteractive `
|
||||
-ExecutionPolicy Bypass `
|
||||
-File $SourceScript `
|
||||
-Mode $Mode 2>&1 | ForEach-Object { "$_" })
|
||||
[pscustomobject]@{
|
||||
mode = $Mode
|
||||
exitCode = $LASTEXITCODE
|
||||
output = $output
|
||||
}
|
||||
}
|
||||
|
||||
function Get-PolicySnapshot {
|
||||
$pathExisted = Test-Path -LiteralPath $PolicyPath
|
||||
$properties = if ($pathExisted) {
|
||||
Get-ItemProperty -LiteralPath $PolicyPath -ErrorAction Stop
|
||||
} else {
|
||||
$null
|
||||
}
|
||||
$values = foreach ($name in $PolicyNames) {
|
||||
$property = if ($properties) { $properties.PSObject.Properties[$name] } else { $null }
|
||||
[pscustomobject]@{
|
||||
name = $name
|
||||
existed = [bool]$property
|
||||
value = if ($property) { [int]$property.Value } else { $null }
|
||||
}
|
||||
}
|
||||
[pscustomobject]@{
|
||||
pathExisted = $pathExisted
|
||||
values = @($values)
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-PolicySnapshot {
|
||||
param([object]$Snapshot)
|
||||
|
||||
if (-not $Snapshot.pathExisted) {
|
||||
if (Test-Path -LiteralPath $PolicyPath) {
|
||||
Remove-Item -LiteralPath $PolicyPath -Recurse -Force
|
||||
}
|
||||
return
|
||||
}
|
||||
New-Item -Path $PolicyPath -Force | Out-Null
|
||||
foreach ($row in @($Snapshot.values)) {
|
||||
if ($row.existed) {
|
||||
New-ItemProperty `
|
||||
-Path $PolicyPath `
|
||||
-Name $row.name `
|
||||
-Value ([int]$row.value) `
|
||||
-PropertyType DWord `
|
||||
-Force | Out-Null
|
||||
} else {
|
||||
Remove-ItemProperty `
|
||||
-LiteralPath $PolicyPath `
|
||||
-Name $row.name `
|
||||
-ErrorAction SilentlyContinue
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function Restore-ServiceStartModes {
|
||||
param([object[]]$Rows)
|
||||
|
||||
foreach ($row in $Rows) {
|
||||
$startupType = switch ($row.startMode) {
|
||||
"Auto" { "Automatic" }
|
||||
"Disabled" { "Disabled" }
|
||||
default { "Manual" }
|
||||
}
|
||||
Set-Service -Name $row.name -StartupType $startupType -ErrorAction Stop
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SourceScript)) {
|
||||
throw "VMware autostart source script not found: $SourceScript"
|
||||
}
|
||||
$task = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
|
||||
if ($task.State -eq "Running") {
|
||||
throw "VMware autostart task is running; controlled apply refused."
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Force -Path $BackupDir | Out-Null
|
||||
New-Item -ItemType Directory -Force -Path (Split-Path -Parent $EvidencePath) | Out-Null
|
||||
$hadStartScript = Test-Path -LiteralPath $StartScript
|
||||
if ($hadStartScript) {
|
||||
Copy-Item -LiteralPath $StartScript -Destination (Join-Path $BackupDir "Start-AWOOOI-VMs.ps1")
|
||||
}
|
||||
$taskXml = Export-ScheduledTask -TaskName $TaskName
|
||||
$taskXml | Set-Content -LiteralPath (Join-Path $BackupDir "$TaskName.xml") -Encoding Unicode
|
||||
$policySnapshot = Get-PolicySnapshot
|
||||
$policySnapshot | ConvertTo-Json -Depth 6 | Set-Content `
|
||||
-LiteralPath (Join-Path $BackupDir "windows-update-policy.json") `
|
||||
-Encoding UTF8
|
||||
$serviceSnapshot = @($ServiceNames | ForEach-Object {
|
||||
$service = Get-CimInstance -ClassName Win32_Service -Filter "Name='$_'" -ErrorAction Stop
|
||||
[pscustomobject]@{ name = $_; startMode = [string]$service.StartMode }
|
||||
})
|
||||
$serviceSnapshot | ConvertTo-Json -Depth 4 | Set-Content `
|
||||
-LiteralPath (Join-Path $BackupDir "vmware-service-start-modes.json") `
|
||||
-Encoding UTF8
|
||||
|
||||
$preVmxProcessCount = @(Get-CimInstance `
|
||||
-ClassName Win32_Process `
|
||||
-Filter "Name='vmware-vmx.exe'" `
|
||||
-ErrorAction Stop).Count
|
||||
$receipt = [ordered]@{
|
||||
schemaVersion = "windows99_vmware_autostart_controlled_apply_v1"
|
||||
traceId = $TraceId
|
||||
sourceRevision = $SourceRevision
|
||||
startedAt = (Get-Date -Format o)
|
||||
completedAt = $null
|
||||
ok = $false
|
||||
status = "started"
|
||||
sourceScript = $SourceScript
|
||||
backupDir = $BackupDir
|
||||
evidence = $EvidencePath
|
||||
expectedVmCount = $ExpectedVmCount
|
||||
preVmxProcessCount = $preVmxProcessCount
|
||||
postVmxProcessCount = $null
|
||||
dryRun = $null
|
||||
apply = $null
|
||||
verifier = $null
|
||||
rollbackAttempted = $false
|
||||
rollbackSucceeded = $null
|
||||
error = $null
|
||||
boundaries = [ordered]@{
|
||||
hostRebootPerformed = $false
|
||||
vmPowerChangePerformed = $false
|
||||
secretReadPerformed = $false
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
$receipt.dryRun = Invoke-AutostartMode -Mode DryRun
|
||||
if ($receipt.dryRun.exitCode -ne 0 -or -not ($receipt.dryRun.output -match "^DRY_RUN ")) {
|
||||
throw "VMware autostart dry-run gate failed."
|
||||
}
|
||||
|
||||
$receipt.apply = Invoke-AutostartMode -Mode Apply
|
||||
if ($receipt.apply.exitCode -ne 0) {
|
||||
throw "VMware autostart apply failed with exit code $($receipt.apply.exitCode)."
|
||||
}
|
||||
|
||||
$receipt.verifier = Invoke-AutostartMode -Mode Verify
|
||||
$postVmxProcessCount = @(Get-CimInstance `
|
||||
-ClassName Win32_Process `
|
||||
-Filter "Name='vmware-vmx.exe'" `
|
||||
-ErrorAction Stop).Count
|
||||
$receipt.postVmxProcessCount = $postVmxProcessCount
|
||||
$runningRows = @($receipt.verifier.output | Where-Object {
|
||||
$_ -match "^VM_POWER alias=.+ vmx_present=1 running=1 source=\S+$"
|
||||
})
|
||||
$verifyReady = $receipt.verifier.output -contains "VMWARE_AUTOSTART_VERIFY_READY=1"
|
||||
$processAwareGuard = [bool](Select-String `
|
||||
-LiteralPath $StartScript `
|
||||
-Pattern "runningProcesses" `
|
||||
-SimpleMatch `
|
||||
-Quiet)
|
||||
$taskAfter = Get-ScheduledTask -TaskName $TaskName -ErrorAction Stop
|
||||
$verified = (
|
||||
$receipt.verifier.exitCode -eq 0 -and
|
||||
$runningRows.Count -eq $ExpectedVmCount -and
|
||||
$verifyReady -and
|
||||
$processAwareGuard -and
|
||||
$taskAfter.State -ne "Disabled" -and
|
||||
$postVmxProcessCount -eq $preVmxProcessCount
|
||||
)
|
||||
$receipt.verifier = [ordered]@{
|
||||
exitCode = $receipt.verifier.exitCode
|
||||
output = $receipt.verifier.output
|
||||
runningVmRows = $runningRows.Count
|
||||
verifyReady = $verifyReady
|
||||
processAwareGuard = $processAwareGuard
|
||||
taskState = [string]$taskAfter.State
|
||||
processCountUnchanged = ($postVmxProcessCount -eq $preVmxProcessCount)
|
||||
}
|
||||
if (-not $verified) {
|
||||
throw "VMware autostart independent post-verifier failed."
|
||||
}
|
||||
$receipt.ok = $true
|
||||
$receipt.status = "completed"
|
||||
} catch {
|
||||
$receipt.status = "failed_rollback_required"
|
||||
$receipt.error = $_.Exception.Message
|
||||
$receipt.rollbackAttempted = $true
|
||||
try {
|
||||
if ($hadStartScript) {
|
||||
Copy-Item `
|
||||
-Force `
|
||||
-LiteralPath (Join-Path $BackupDir "Start-AWOOOI-VMs.ps1") `
|
||||
-Destination $StartScript
|
||||
} elseif (Test-Path -LiteralPath $StartScript) {
|
||||
Remove-Item -LiteralPath $StartScript -Force
|
||||
}
|
||||
Register-ScheduledTask -TaskName $TaskName -Xml $taskXml -Force | Out-Null
|
||||
Restore-PolicySnapshot -Snapshot $policySnapshot
|
||||
Restore-ServiceStartModes -Rows $serviceSnapshot
|
||||
$receipt.rollbackSucceeded = $true
|
||||
$receipt.status = "failed_rolled_back"
|
||||
} catch {
|
||||
$receipt.rollbackSucceeded = $false
|
||||
$receipt.status = "failed_rollback_failed"
|
||||
$receipt.error = "$($receipt.error); rollback=$($_.Exception.Message)"
|
||||
}
|
||||
} finally {
|
||||
$receipt.completedAt = Get-Date -Format o
|
||||
$receipt | ConvertTo-Json -Depth 12 | Set-Content -LiteralPath $EvidencePath -Encoding UTF8
|
||||
}
|
||||
|
||||
$receipt | ConvertTo-Json -Depth 12
|
||||
if (-not $receipt.ok) {
|
||||
exit 2
|
||||
}
|
||||
exit 0
|
||||
Reference in New Issue
Block a user