Public/Invoke-HDTBootReconciliation.ps1
|
function Invoke-HDTBootReconciliation { <# .SYNOPSIS Decides on every boot whether to resume the run or disarm the machine. .DESCRIPTION "Start-HDTResume.ps1 reconciles on every boot: if the state document says the run is finished, failed, or missing, it clears autologon, the LSA secret, the RunOnce entry, and C:\HDT\state.json BEFORE DOING ANYTHING ELSE." Four outcomes: the state file does not exist Teardown 'no state document' it cannot be read or parsed Teardown 'unreadable state document' Succeeded or Failed Teardown 'run finished' Running but stale past -MaxAgeHour Teardown 'run abandoned' otherwise Resume 'resuming at step n' Teardown runs the full autologon checklist through Clear-HDTAutoLogon and then removes the state file - in that order, so a crash between the two leaves a disarmed machine rather than an armed one with no state to reconcile against next time. It does not remove a state file that was not there. Resume increments the leg, logs one reboot.resume record, and returns the state. IT DOES NOT RUN THE SEQUENCE - the caller does, which is 03-04's Start-HDTResume.ps1. Keeping the decision separate from the execution is what lets every branch above be proven against fakes. THE UNREADABLE CASE IS THE ONE PLACE Import-HDTRunState's throw IS CAUGHT, and it is deliberate: on a boot, a corrupt state document is not evidence that a run is alive. The safe reading is "disarm". It is also the only branch here that swallows an exception, so the parse message is logged at Warning rather than lost. WHY THIS MATTERS MORE THAN AutoLogonCount. SPIKES.md S8 measured the third backstop: Windows only disarms itself once the count is spent, so an abandoned run keeps autologging on for up to n more boots. This function is what turns that into one boot. Every time reading goes through -Clock. A test asserts that by reading this file's own text, which is why the cmdlet it forbids is not named here. .PARAMETER StatePath Where state.json lives. Normally C:\HDT\state.json. .PARAMETER FileSystem An IFileSystem. .PARAMETER Registry An IRegistryService. .PARAMETER Lsa An ILsaService. .PARAMETER Clock An IClock. The only source of the current time. .PARAMETER LogContext A log context. Without it the decision is still made and still acted on - a boot with no writable log is not a boot that gets to stay armed - but nothing is written. .PARAMETER UnattendPath Passed through to Clear-HDTAutoLogon. .PARAMETER MaxAgeHour How stale a Running state may be before the run counts as abandoned. Defaults to 12. .OUTPUTS System.Management.Automation.PSCustomObject with Action ('Resume' or 'Teardown'), Reason and State. .EXAMPLE $decision = Invoke-HDTBootReconciliation -StatePath 'C:\HDT\state.json' ` -FileSystem $fs -Registry $registry -Lsa $lsa -Clock $clock -LogContext $log if ($decision.Action -eq 'Resume') { ... } #> [CmdletBinding()] [OutputType([pscustomobject])] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string] $StatePath, [Parameter(Mandatory = $true)] [ValidateNotNull()] [object] $FileSystem, [Parameter(Mandatory = $true)] [ValidateNotNull()] [object] $Registry, [Parameter(Mandatory = $true)] [ValidateNotNull()] [object] $Lsa, [Parameter(Mandatory = $true)] [ValidateNotNull()] [object] $Clock, [Parameter()] [AllowNull()] [object] $LogContext, [Parameter()] [ValidateNotNullOrEmpty()] [string[]] $UnattendPath, [Parameter()] [ValidateRange(0, [int]::MaxValue)] [int] $MaxAgeHour = 12 ) Set-StrictMode -Version Latest $ErrorActionPreference = 'Stop' $teardownArgument = @{ Registry = $Registry Lsa = $Lsa FileSystem = $FileSystem LogContext = $LogContext } if ($PSBoundParameters.ContainsKey('UnattendPath')) { $teardownArgument['UnattendPath'] = $UnattendPath } $state = $null $reason = $null $stateFileExists = $FileSystem.TestPath($StatePath) if (-not $stateFileExists) { $reason = 'no state document' } else { try { $state = Import-HDTRunState -Path $StatePath -FileSystem $FileSystem } catch { # The only swallowed exception in this function. A corrupt state # document is not evidence that a run is alive. $reason = 'unreadable state document' $state = $null if ($null -ne $LogContext) { Write-HDTLog -Context $LogContext -Event 'reboot.teardown' -Severity 'Warning' ` -Message ("State document '{0}' could not be read, disarming: {1}" -f $StatePath, $_.Exception.Message) } } } if ($null -eq $reason) { if (Test-HDTRunStateAbandoned -State $state -Clock $Clock -MaxAgeHour $MaxAgeHour) { $reason = 'run abandoned' if (@('Succeeded', 'Failed') -contains [string] $state.status) { $reason = 'run finished' } } } if ($null -ne $reason) { # Disarm FIRST, then drop the state file. A crash in between leaves a # machine that will not autologon, which is the safe half to be left in. Clear-HDTAutoLogon @teardownArgument -State $state | Out-Null if ($stateFileExists) { $FileSystem.RemoveItem($StatePath, $false) } return [pscustomobject] ([ordered] @{ Action = 'Teardown' Reason = $reason State = $null }) } $state.leg = [int] $state.leg + 1 $reason = 'resuming at step {0}' -f $state.stepIndex if ($null -ne $LogContext) { Write-HDTLog -Context $LogContext -Event 'reboot.resume' ` -Message ("Resuming run {0} at step {1} on leg {2}" -f $state.runId, $state.stepIndex, $state.leg) ` -Data ([ordered] @{ runId = $state.runId stepIndex = $state.stepIndex leg = $state.leg }) } return [pscustomobject] ([ordered] @{ Action = 'Resume' Reason = $reason State = $state }) } |