Runtime/OSDStageFinalAction.ps1

# Standalone finalization. No module import, startup trigger or retry after reboot.
function Get-OSDStageFinalAction {
    param([Parameter(Mandatory)]$Runtime)
    if ($null -eq $Runtime.PSObject.Properties['FinalAction']) { return 'None' }
    if ($Runtime.FinalAction -isnot [string] -or $Runtime.FinalAction -notin @('None','Reboot','Shutdown')) {
        throw 'Runtime.FinalAction must be None, Reboot or Shutdown.'
    }
    return [string]$Runtime.FinalAction
}

function New-OSDStageFinalActionState {
    param([string]$Action = 'None', [string]$Status = 'NotRequested', [string]$Reason)
    [pscustomobject]@{ Action=$Action; Status=$Status; Reason=$Reason; RequestedAtUtc=$null }
}

function Write-OSDStageFinalActionJson {
    param([string]$Path, $Value)
    $temporary = "$Path.$([guid]::NewGuid().ToString('N')).tmp"
    try {
        $Value | ConvertTo-Json -Depth 30 | Set-Content -LiteralPath $temporary -Encoding UTF8 -ErrorAction Stop
        Move-Item -LiteralPath $temporary -Destination $Path -Force -ErrorAction Stop
    }
    finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue } }
}

function Get-OSDStageFinalActionProcesses {
    param([int]$RunnerId)
    $items = [Collections.Generic.List[object]]::new()
    $currentId = $RunnerId
    $foundScript = $false
    $foundSetup = $false
    for ($depth = 0; $depth -lt 16 -and $currentId -gt 0; $depth++) {
        $instance = Get-CimInstance Win32_Process -Filter "ProcessId=$currentId" -ErrorAction Stop
        if (-not $instance) { throw 'Cannot establish the SetupComplete process lifetime.' }
        $process = [Diagnostics.Process]::GetProcessById($currentId)
        try {
            if ($process.HasExited) { throw 'Setup process already exited before finalization was armed.' }
            $items.Add([pscustomobject]@{ Id=$currentId; StartTicks=$process.StartTime.ToUniversalTime().Ticks; Name=[string]$instance.Name })
        }
        finally { $process.Dispose() }
        if ($instance.Name -ieq 'cmd.exe' -and [string]$instance.CommandLine -match '(?i)(?:[\\/]|\b)SetupComplete\.cmd(?:["\s]|$)') { $foundScript = $true }
        if ($instance.Name -ieq 'windeploy.exe') { $foundSetup = $true; break }
        # Never wait for long-lived system ancestors (winlogon/services) or infer completion from a delay.
        if ($instance.Name -notin @('powershell.exe','pwsh.exe','cmd.exe')) { break }
        $currentId = [int]$instance.ParentProcessId
    }
    if (-not $foundScript -or -not $foundSetup) { throw 'FinalAction requires the standard SetupComplete.cmd / windeploy.exe execution context.' }
    return $items.ToArray()
}

function Start-OSDStageFinalAction {
    param([ValidateSet('Reboot','Shutdown')][string]$Action, [string]$DeploymentId,
        [string]$RunnerStartedAtUtc, [string]$StagedPath, [string]$ResultPath,
        [ValidateSet('Never','OnSuccess')][string]$CleanupMode)
    if ([Environment]::OSVersion.Platform -ne [PlatformID]::Win32NT -or
        (Test-Path -LiteralPath 'HKLM:\SYSTEM\CurrentControlSet\Control\MiniNT')) {
        throw 'FinalAction is only available in full Windows.'
    }
    $id = [guid]::Parse($DeploymentId)
    if ($id -eq [guid]::Empty) { throw 'FinalAction requires a deployment identity.' }
    $processes = @(Get-OSDStageFinalActionProcesses -RunnerId $PID)
    $stage = [IO.Path]::GetFullPath($StagedPath).TrimEnd('\','/')
    $result = [IO.Path]::GetFullPath($ResultPath)
    $root = Join-Path $env:ProgramData ('OSDStage\FinalActions\' + $id.Guid)
    foreach ($retained in @($result,$root)) {
        if ($retained.StartsWith($stage + [IO.Path]::DirectorySeparatorChar,[StringComparison]::OrdinalIgnoreCase)) {
            throw 'FinalAction results and worker must be outside the staging directory.'
        }
    }
    if (Test-Path -LiteralPath $root) { throw 'FinalAction was already armed for this deployment; it will not be retried.' }
    New-Item -ItemType Directory -Path $root -ErrorAction Stop | Out-Null
    # Only SYSTEM and local administrators may change the deferred power request.
    $acl = [Security.AccessControl.DirectorySecurity]::new()
    $acl.SetAccessRuleProtection($true,$false)
    foreach ($sid in @('S-1-5-18','S-1-5-32-544')) {
        $rule = [Security.AccessControl.FileSystemAccessRule]::new(
            [Security.Principal.SecurityIdentifier]::new($sid), 'FullControl', 'ContainerInherit,ObjectInherit', 'None', 'Allow')
        $acl.AddAccessRule($rule)
    }
    Set-Acl -LiteralPath $root -AclObject $acl -ErrorAction Stop
    foreach ($file in @('OSDStageFinalAction.ps1','Invoke-OSDStageFinalAction.ps1','OSDStageDeliveryReport.ps1')) {
        Copy-Item -LiteralPath (Join-Path $PSScriptRoot $file) -Destination (Join-Path $root $file) -ErrorAction Stop
    }
    $taskName = 'OSDStage-FinalAction-' + $id.Guid
    $requestPath = Join-Path $root 'Request.json'
    $request = [pscustomobject]@{
        DeploymentId=$id.Guid; Action=$Action; RunnerStartedAtUtc=$RunnerStartedAtUtc
        StagedPath=$stage; ResultPath=$result; CleanupMode=$CleanupMode; Processes=$processes
        ManifestHash=(Get-FileHash -LiteralPath (Join-Path $stage 'DeviceManifest.json') -Algorithm SHA256).Hash
        TaskName=$taskName; TimeoutSeconds=1800
    }
    Write-OSDStageFinalActionJson -Path $requestPath -Value $request
    $workerPath = Join-Path $root 'Invoke-OSDStageFinalAction.ps1'
    $engine = Join-Path $env:windir 'System32\WindowsPowerShell\v1.0\powershell.exe'
    $arguments = '-NoProfile -NonInteractive -ExecutionPolicy Bypass -File "{0}" -RequestPath "{1}"' -f $workerPath,$requestPath
    try {
        $taskAction = New-ScheduledTaskAction -Execute $engine -Argument $arguments
        $principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
        $settings = New-ScheduledTaskSettingsSet -ExecutionTimeLimit (New-TimeSpan -Minutes 35) -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries
        Register-ScheduledTask -TaskName $taskName -Action $taskAction -Principal $principal -Settings $settings -ErrorAction Stop | Out-Null
        Start-ScheduledTask -TaskName $taskName -ErrorAction Stop
        $watch = [Diagnostics.Stopwatch]::StartNew()
        while (-not (Test-Path -LiteralPath (Join-Path $root 'Ready'))) {
            if ($watch.Elapsed.TotalSeconds -ge 30 -or (Test-Path -LiteralPath (Join-Path $root 'Finished.json'))) {
                throw 'FinalAction worker did not attach to the live SetupComplete processes.'
            }
            Start-Sleep -Milliseconds 200
        }
    }
    catch {
        Set-Content -LiteralPath (Join-Path $root 'Cancel') -Value 'FinalAction registration failed.' -Encoding UTF8
        Stop-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
        Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
        throw
    }
}

function Open-OSDStageFinalActionProcess {
    param($Identity)
    $process = [Diagnostics.Process]::GetProcessById([int]$Identity.Id)
    try {
        $null = $process.Handle
        if ($process.HasExited -or $process.StartTime.ToUniversalTime().Ticks -ne [long]$Identity.StartTicks) {
            throw 'A SetupComplete process exited or its PID was reused before the worker attached.'
        }
        return $process
    }
    catch { $process.Dispose(); throw }
}

function Wait-OSDStageFinalActionProcesses {
    param($Request, [string]$Directory)
    $handles = [Collections.Generic.List[object]]::new()
    try {
        foreach ($identity in $Request.Processes) {
            # Pin the process identity and exit status before acknowledging Ready.
            $handles.Add((Open-OSDStageFinalActionProcess -Identity $identity))
        }
        if ($handles.Count -lt 3) { throw 'Incomplete SetupComplete process evidence.' }
        Set-Content -LiteralPath (Join-Path $Directory 'Ready') -Value 'Process handles acquired.' -Encoding UTF8 -ErrorAction Stop
        $watch = [Diagnostics.Stopwatch]::StartNew()
        while (@($handles | Where-Object { -not $_.HasExited }).Count -gt 0) {
            if (Test-Path -LiteralPath (Join-Path $Directory 'Cancel')) { throw 'FinalAction was cancelled.' }
            if ($watch.Elapsed.TotalSeconds -ge [int]$Request.TimeoutSeconds) { throw 'SetupComplete did not finish within the finalization wait limit; no power action requested.' }
            Start-Sleep -Milliseconds 500
        }
        foreach ($process in $handles) {
            if ($process.ExitCode -ne 0) { throw "A deployment/SetupComplete process exited with code $($process.ExitCode); no power action requested." }
        }
    }
    finally { foreach ($process in $handles) { $process.Dispose() } }
}

function Get-OSDStageFinalActionEvidence {
    param($Request)
    $result = Get-Content -LiteralPath $Request.ResultPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    $directory = Split-Path -Parent $Request.ResultPath
    $report = Get-Content -LiteralPath (Join-Path $directory 'DeliveryReport.json') -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    if ($result.DeploymentId -ne $Request.DeploymentId -or $report.DeploymentId -ne $Request.DeploymentId -or
        [string]$result.StartedAtUtc -ne [string]$Request.RunnerStartedAtUtc -or
        $result.Status -ne 'Succeeded' -or $report.ExecutionStatus -ne 'Succeeded' -or
        [string]$report.StartedAtUtc -ne [string]$Request.RunnerStartedAtUtc -or
        [string]$report.CompletedAtUtc -ne [string]$result.CompletedAtUtc -or
        $result.UnsuccessfulPackageCount -ne 0 -or $result.SuccessfulPackageCount -ne $result.ExpectedPackageCount -or
        @($result.Packages).Count -ne $result.ExpectedPackageCount -or
        @($result.Packages | Where-Object Status -ne 'Succeeded').Count -gt 0 -or
        $result.FinalAction.Action -ne $Request.Action -or $result.FinalAction.Status -ne 'Waiting') {
        throw 'FinalAction has no matching, complete successful runner result; the request may have been superseded.'
    }
    if (-not (Test-Path -LiteralPath (Join-Path $directory 'DeliveryReport.html') -PathType Leaf)) { throw 'Delivery report HTML is missing.' }
    return [pscustomobject]@{ Result=$result; Report=$report; Directory=$directory }
}

function Save-OSDStageFinalActionOutcome {
    param($Request, [string]$Directory, [string]$Status, [string]$Reason)
    $state = New-OSDStageFinalActionState -Action $Request.Action -Status $Status -Reason $Reason
    if ($Status -eq 'Requested') { $state.RequestedAtUtc = [datetime]::UtcNow.ToString('o') }
    Write-OSDStageFinalActionJson -Path (Join-Path $Directory 'Finished.json') -Value $state
    # Never overwrite another deployment or a newer execution of this deployment.
    $result = Get-Content -LiteralPath $Request.ResultPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    if ($result.DeploymentId -ne $Request.DeploymentId -or [string]$result.StartedAtUtc -ne [string]$Request.RunnerStartedAtUtc) { throw 'FinalAction result was superseded.' }
    $reportDirectory = Split-Path -Parent $Request.ResultPath
    $reportPath = Join-Path $reportDirectory 'DeliveryReport.json'
    $report = Get-Content -LiteralPath $reportPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    if ($report.DeploymentId -ne $Request.DeploymentId -or
        [string]$report.StartedAtUtc -ne [string]$Request.RunnerStartedAtUtc -or
        [string]$report.CompletedAtUtc -ne [string]$result.CompletedAtUtc) { throw 'FinalAction report was superseded.' }
    $result | Add-Member -NotePropertyName FinalAction -NotePropertyValue $state -Force
    $report | Add-Member -NotePropertyName FinalAction -NotePropertyValue (ConvertTo-OSDStageReportFinalAction -State $state) -Force
    if ($Status -in @('Skipped','Failed') -and $report.Status -eq 'Succeeded') {
        $report.Status = 'Attention'
        $report.Summary = 'Execution succeeded; the configured final action was not requested. Review final action details.'
    }
    Write-OSDStageFinalActionJson -Path $Request.ResultPath -Value $result
    Write-OSDStageFinalActionJson -Path $reportPath -Value $report
    $htmlPath = Join-Path $reportDirectory 'DeliveryReport.html'
    $temporary = "$htmlPath.finalaction.tmp"
    try {
        ConvertTo-OSDStageReportHtml -Report $report | Set-Content -LiteralPath $temporary -Encoding UTF8 -ErrorAction Stop
        Move-Item -LiteralPath $temporary -Destination $htmlPath -Force -ErrorAction Stop
    }
    finally { if (Test-Path -LiteralPath $temporary) { Remove-Item -LiteralPath $temporary -Force -ErrorAction SilentlyContinue } }
}

function Invoke-OSDStageFinalPowerAction {
    param([ValidateSet('Reboot','Shutdown')][string]$Action)
    $operation = if ($Action -eq 'Reboot') { '/r' } else { '/s' }
    # /t 0 avoids the implicit force-close behavior of a positive shutdown timeout.
    # No /f: unrelated apps are not forcibly terminated.
    $process = Start-Process -FilePath (Join-Path $env:windir 'System32\shutdown.exe') -ArgumentList @($operation,'/t','0') -Wait -PassThru -ErrorAction Stop
    if ($process.ExitCode -ne 0) { throw "Windows rejected the final action (exit code $($process.ExitCode))." }
}

function Invoke-OSDStageFinalAction {
    param([Parameter(Mandatory)][string]$RequestPath)
    $directory = Split-Path -Parent $RequestPath
    $request = Get-Content -LiteralPath $RequestPath -Raw -Encoding UTF8 -ErrorAction Stop | ConvertFrom-Json -ErrorAction Stop
    if ($request.Action -notin @('Reboot','Shutdown')) { throw 'Invalid final action request.' }
    $claim = Join-Path $directory 'Claimed'
    # CreateNew is atomic. Even a duplicate/manual invocation cannot request power twice.
    try { $claimHandle = [IO.File]::Open($claim,[IO.FileMode]::CreateNew,[IO.FileAccess]::Write,[IO.FileShare]::None); $claimHandle.Dispose() }
    catch [IO.IOException] { return }
    try {
        Wait-OSDStageFinalActionProcesses -Request $request -Directory $directory
        if (Test-Path -LiteralPath (Join-Path $directory 'Cancel')) { throw 'FinalAction was cancelled.' }
        $null = Get-OSDStageFinalActionEvidence -Request $request
        $manifestPath = Join-Path $request.StagedPath 'DeviceManifest.json'
        if ((Get-FileHash -LiteralPath $manifestPath -Algorithm SHA256 -ErrorAction Stop).Hash -ne $request.ManifestHash) {
            throw 'Staged deployment changed while waiting; cleanup and final action were skipped.'
        }
        if ($request.CleanupMode -eq 'OnSuccess') {
            Remove-Item -LiteralPath $request.StagedPath -Recurse -Force -ErrorAction Stop
            if (Test-Path -LiteralPath $request.StagedPath) { throw 'Runtime cleanup did not finish.' }
        }
        elseif ($request.CleanupMode -ne 'Never') { throw 'Unsupported finalization cleanup mode.' }
        Unregister-ScheduledTask -TaskName $request.TaskName -Confirm:$false -ErrorAction Stop
        Save-OSDStageFinalActionOutcome -Request $request -Directory $directory -Status Requested -Reason 'Runner, SetupComplete and Windows Setup exited successfully; configured cleanup finished. The power request is being submitted, not verified as completed.'
        # LAST operation: every result/report write, process wait and cleanup precedes this call.
        Invoke-OSDStageFinalPowerAction -Action $request.Action
    }
    catch {
        $reason = $_.Exception.Message
        Unregister-ScheduledTask -TaskName $request.TaskName -Confirm:$false -ErrorAction SilentlyContinue
        try { Save-OSDStageFinalActionOutcome -Request $request -Directory $directory -Status Skipped -Reason $reason }
        catch {
            # A missing/superseded report must not hide the reason or permit a power request.
            Write-OSDStageFinalActionJson -Path (Join-Path $directory 'Finished.json') -Value (New-OSDStageFinalActionState -Action $request.Action -Status Skipped -Reason $reason)
        }
    }
}