feat(recovery): add controlled VMware autostart apply

This commit is contained in:
ogt
2026-07-11 17:34:46 +08:00
parent 152fcbca05
commit 198bf24ff4
2 changed files with 280 additions and 0 deletions

View File

@@ -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

View File

@@ -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