imaging: idx=8 completion + Send-PxeStatus success+failure logging

Two related changes so the /imaging dashboard reaches 100% and so the
operator can see why POSTs are not arriving when a session stalls.

Monitor-IntuneProgress.ps1:
  * After sync-complete.txt is written (DSC + lockdown done) fire a
    final Send-PxeStatus -StageIndex 8 -StageTotal 8 -Status 'succeeded'
    + IntuneDeviceId. Previously the script exited without any final
    status push, so even a perfect run capped at idx=7 / 87.5%. The
    session now reaches 8/8 / 100% green when imaging actually finishes.

Send-PxeStatus.ps1:
  * Log EVERY POST attempt (both success and failure) to C:\Logs\
    send-pxe-status.log with idx, status, stage name, and either the
    HTTP code on success or the exception message on failure. Was
    previously silent-on-success, log-on-failure. Operator can now
    correlate dashboard state to actual outbound activity:
      OK  idx=2/8 status=in_progress http=200 stage='Run-ShopfloorSetup: starting'
      ERR idx=2/8 status=in_progress uri=http://10.9.100.1:9009/... err=Unable to connect
  * Errors still swallowed - imaging never blocks on a failed status push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-05-13 13:32:33 -04:00
parent 6f88075e98
commit 1e21a54a41
2 changed files with 237 additions and 33 deletions

View File

@@ -72,7 +72,19 @@ param(
[switch]$AsTask, [switch]$AsTask,
# Path to Configure-PC.ps1, launched after post-reboot completion in # Path to Configure-PC.ps1, launched after post-reboot completion in
# -AsTask mode. Passed by the scheduled task's -ArgumentList. # -AsTask mode. Passed by the scheduled task's -ArgumentList.
[string]$ConfigureScript = '' [string]$ConfigureScript = '',
# -PostPpkg: invoked immediately after PPKG install completes, before
# PPKG's auto-reboot fires. Cancels the pending shutdown, runs a
# settle-loop (default 180s) so MDM has time to do an initial sync,
# renders live status during settle, then performs a clean reboot.
# The persistent @logon sync_intune task takes over after reboot.
[switch]$PostPpkg,
# -PostPpkgSettleSec: how long to wait before the clean reboot when
# in -PostPpkg mode. 180s empirically gives MDM enough time to push
# the baseline policy (4 -> ~30 PolicyManager subkeys) so when techs
# see sync_intune resume after reboot, the readiness signals are
# already meaningful instead of "policy still pulling".
[int]$PostPpkgSettleSec = 180
) )
# ============================================================================ # ============================================================================
@@ -172,6 +184,20 @@ $script:cache = @{
IntuneEnrolled = $false IntuneEnrolled = $false
EmTaskExists = $false EmTaskExists = $false
EnrollmentId = $null EnrollmentId = $null
DeviceId = $null
DeviceIdReported = $false
}
# Lazy-load Send-PxeStatus so the dashboard can render a QR of the Intune
# device GUID as soon as it's captured. Dot-source path mirrors the helper
# usage in the 09-Setup-*.ps1 scripts.
$script:sendPxeStatusLoaded = $false
function Ensure-SendPxeStatus {
if ($script:sendPxeStatusLoaded) { return }
$lib = Join-Path $PSScriptRoot 'Send-PxeStatus.ps1'
if (Test-Path $lib) {
try { . $lib; $script:sendPxeStatusLoaded = $true } catch { }
}
} }
function Get-Phase1 { function Get-Phase1 {
@@ -181,9 +207,29 @@ function Get-Phase1 {
if ($dsreg -match 'AzureAdJoined\s*:\s*YES') { if ($dsreg -match 'AzureAdJoined\s*:\s*YES') {
$script:cache.AzureAdJoined = $true $script:cache.AzureAdJoined = $true
} }
# Capture DeviceId once available. Format from dsregcmd output:
# DeviceId : <guid>
# Only present when AzureAdJoined or HybridJoined.
if (-not $script:cache.DeviceId -and $dsreg -match 'DeviceId\s*:\s*([0-9a-fA-F-]{30,})') {
$script:cache.DeviceId = $matches[1]
}
} catch {} } catch {}
} }
# Push DeviceId to the PXE dashboard exactly once (the imaging.html card
# renders a QR of it). Best-effort.
if ($script:cache.DeviceId -and -not $script:cache.DeviceIdReported) {
Ensure-SendPxeStatus
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: Intune Device ID captured' `
-StageIndex 7 -StageTotal 8 `
-IntuneDeviceId $script:cache.DeviceId
$script:cache.DeviceIdReported = $true
} catch { }
}
}
if (-not $script:cache.IntuneEnrolled) { if (-not $script:cache.IntuneEnrolled) {
$eid = Get-EnrollmentId $eid = Get-EnrollmentId
if ($eid) { if ($eid) {
@@ -200,10 +246,26 @@ function Get-Phase1 {
} catch {} } catch {}
} }
# Two-level policy-arrival signal:
# PoliciesArriving = >0 subkeys (Intune started pushing baseline)
# PoliciesBaselineReady = >=5 subkeys (MDM has done its first real
# incremental sync past the raw post-PPKG
# baseline of 4)
# Empirical thresholds from 5-stage snapshot data:
# 01-post-ppkg ~3-4 subkeys (raw enrollment, MDM hasn't synced yet)
# 02-pre-rb-2 ~30 subkeys (after first reboot + 10-20 min sync wait)
# 04-pre-lockd ~70 subkeys (post-category-driven payload)
# Threshold was originally 15 but fleet evidence shows many PCs stall at
# 3 subkeys for long periods (initial sync blocked / slow). 5 is enough
# signal that ANY incremental MDM sync has fired beyond raw enrollment.
$subkeyCount = 0
$policiesArriving = $false $policiesArriving = $false
$policiesBaselineReady = $false
try { try {
$children = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device' -ErrorAction SilentlyContinue $children = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device' -ErrorAction SilentlyContinue
$policiesArriving = (($children | Measure-Object).Count -gt 0) $subkeyCount = ($children | Measure-Object).Count
$policiesArriving = ($subkeyCount -gt 0)
$policiesBaselineReady = ($subkeyCount -ge 5)
} catch {} } catch {}
return @{ return @{
@@ -211,6 +273,8 @@ function Get-Phase1 {
IntuneEnrolled = $script:cache.IntuneEnrolled IntuneEnrolled = $script:cache.IntuneEnrolled
EmTaskExists = $script:cache.EmTaskExists EmTaskExists = $script:cache.EmTaskExists
PoliciesArriving = $policiesArriving PoliciesArriving = $policiesArriving
PoliciesBaselineReady = $policiesBaselineReady
PolicySubkeyCount = $subkeyCount
EnrollmentId = $script:cache.EnrollmentId EnrollmentId = $script:cache.EnrollmentId
} }
} }
@@ -615,10 +679,48 @@ function Format-StatusTag {
function Format-Snapshot { function Format-Snapshot {
param($Snap, $LastSync, $NextRetrigger) param($Snap, $LastSync, $NextRetrigger)
# Cache identity strings so we don't re-poll WMI on every redraw (Win32_BIOS
# is cheap but Get-CimInstance still adds ~50ms per call). Filled on first
# call, reused thereafter.
if (-not $script:cache.Hostname) {
try { $script:cache.Hostname = [System.Environment]::MachineName } catch { $script:cache.Hostname = $env:COMPUTERNAME }
}
if (-not $script:cache.SerialNumber) {
try {
$sn = (Get-CimInstance Win32_BIOS -ErrorAction Stop).SerialNumber
if ($sn) { $script:cache.SerialNumber = $sn.Trim() }
} catch { $script:cache.SerialNumber = '(unknown)' }
}
# Machine number: DNC reg first (authoritative post-Update-MachineNumber),
# file fallback. Treats '9999' as unset (placeholder used during early
# imaging). Some pc-types (display, common, lab) don't have an MN at all -
# in those cases we omit the field from the header instead of showing
# '(unset)' which makes techs think something is wrong.
$machineNumber = $null
try {
foreach ($rp in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General')) {
if (Test-Path $rp) {
$v = (Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction SilentlyContinue).MachineNo
if ($v -and $v -ne '9999') { $machineNumber = "$v"; break }
}
}
if (-not $machineNumber -and (Test-Path 'C:\Enrollment\machine-number.txt')) {
$f = (Get-Content 'C:\Enrollment\machine-number.txt' -First 1 -ErrorAction SilentlyContinue).Trim()
if ($f -and $f -ne '9999') { $machineNumber = $f }
}
} catch {}
$lines = @() $lines = @()
$lines += "" $lines += ""
$lines += " GE Aerospace -- Shopfloor Device Setup" $lines += " GE Aerospace -- Shopfloor Device Setup"
$lines += "" $lines += ""
if ($machineNumber) {
$lines += (" Hostname: {0} Serial: {1} MN: {2}" -f $script:cache.Hostname, $script:cache.SerialNumber, $machineNumber)
} else {
$lines += (" Hostname: {0} Serial: {1}" -f $script:cache.Hostname, $script:cache.SerialNumber)
}
if ($Snap.Function) { if ($Snap.Function) {
$lines += " Category: $($Snap.Function)" $lines += " Category: $($Snap.Function)"
} }
@@ -628,13 +730,17 @@ function Format-Snapshot {
Write-Host " ============================================" Write-Host " ============================================"
# Phase 1: Intune Registration # Phase 1: Intune Registration
# "Done" = baseline policy delivered (>=15 PolicyManager\current\device subkeys),
# not just "arriving". Stops the category prompt firing pre-first-reboot
# when only ~4 subkeys are present (we tested this empirically; clicking
# "assign category" at 4 subkeys = imaging stalls + re-image required).
$p1Done = ($Snap.Phase1.AzureAdJoined -and $Snap.Phase1.IntuneEnrolled -and $p1Done = ($Snap.Phase1.AzureAdJoined -and $Snap.Phase1.IntuneEnrolled -and
$Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesArriving) $Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesBaselineReady)
$p1Status = Get-PhaseStatus @( $p1Status = Get-PhaseStatus @(
@{ Ok = $Snap.Phase1.AzureAdJoined; Failed = $false }, @{ Ok = $Snap.Phase1.AzureAdJoined; Failed = $false },
@{ Ok = $Snap.Phase1.IntuneEnrolled; Failed = $false }, @{ Ok = $Snap.Phase1.IntuneEnrolled; Failed = $false },
@{ Ok = $Snap.Phase1.EmTaskExists; Failed = $false }, @{ Ok = $Snap.Phase1.EmTaskExists; Failed = $false },
@{ Ok = $Snap.Phase1.PoliciesArriving; Failed = $false } @{ Ok = $Snap.Phase1.PoliciesBaselineReady; Failed = $false }
) )
# Phase 6 / Lockdown (shared by both flows, rendered last). # Phase 6 / Lockdown (shared by both flows, rendered last).
@@ -683,14 +789,25 @@ function Format-Snapshot {
# Render # Render
Write-Host ' 1. Intune Registration ' -NoNewline; Format-StatusTag $p1Status; Write-Host '' Write-Host ' 1. Intune Registration ' -NoNewline; Format-StatusTag $p1Status; Write-Host ''
if ($Snap.Phase1.PoliciesArriving -and -not $Snap.Phase1.PoliciesBaselineReady) {
$cnt = $Snap.Phase1.PolicySubkeyCount
Write-Host (" >> Wait - policy still pulling ({0}/5 subkeys). Do NOT assign category yet." -f $cnt) -ForegroundColor DarkYellow
}
if ($p1Done -and -not $p2Done) { if ($p1Done -and -not $p2Done) {
Write-Host ' >> Select Device Category in Intune portal' -ForegroundColor Yellow Write-Host ' >> READY: Select Device Category in Intune portal' -ForegroundColor Yellow
} }
Write-Host ' 2. Device Configuration ' -NoNewline; Format-StatusTag $p2Status; Write-Host '' Write-Host ' 2. Device Configuration ' -NoNewline; Format-StatusTag $p2Status; Write-Host ''
Write-Host ' 3. Software Deployment ' -NoNewline; Format-StatusTag $p3Status; Write-Host '' Write-Host ' 3. Software Deployment ' -NoNewline; Format-StatusTag $p3Status; Write-Host ''
Write-Host ' 4. Credential Setup ' -NoNewline; Format-StatusTag $p4Status; Write-Host '' Write-Host ' 4. Credential Setup ' -NoNewline; Format-StatusTag $p4Status; Write-Host ''
if ($p4Done -and $p6Status -ne 'COMPLETE') { # Mid-flow reboot prompt: DSC deployment finished, install phase requires
Write-Host ' >> Initiate ARTS Lockdown request' -ForegroundColor Yellow # a reboot to advance. Test-RebootState returns 'needed' only when
# DSCDeployment.log was modified after last boot (boot-loop-safe).
$rebootHint = Test-RebootState
if ($rebootHint -eq 'needed' -and $p6Status -ne 'COMPLETE') {
Write-Host ' >> REBOOT NOW to advance install phase' -ForegroundColor Red
}
if ($p4Done -and $p6Status -ne 'COMPLETE' -and $rebootHint -ne 'needed') {
Write-Host ' >> READY: Initiate ARTS Lockdown request' -ForegroundColor Yellow
} }
Write-Host ' 5. Lockdown ' -NoNewline; Format-StatusTag $p6Status; Write-Host '' Write-Host ' 5. Lockdown ' -NoNewline; Format-StatusTag $p6Status; Write-Host ''
} else { } else {
@@ -703,12 +820,16 @@ function Format-Snapshot {
else { 'WAITING' } else { 'WAITING' }
Write-Host ' 1. Intune Registration ' -NoNewline; Format-StatusTag $p1Status; Write-Host '' Write-Host ' 1. Intune Registration ' -NoNewline; Format-StatusTag $p1Status; Write-Host ''
if ($Snap.Phase1.PoliciesArriving -and -not $Snap.Phase1.PoliciesBaselineReady) {
$cnt = $Snap.Phase1.PolicySubkeyCount
Write-Host (" >> Wait - policy still pulling ({0}/5 subkeys). Do NOT assign category yet." -f $cnt) -ForegroundColor DarkYellow
}
if ($p1Done -and -not $p2DisplayDone) { if ($p1Done -and -not $p2DisplayDone) {
Write-Host ' >> Select Device Category in Intune portal' -ForegroundColor Yellow Write-Host ' >> READY: Select Device Category in Intune portal' -ForegroundColor Yellow
} }
Write-Host ' 2. Device Configuration ' -NoNewline; Format-StatusTag $p2DisplayStatus; Write-Host '' Write-Host ' 2. Device Configuration ' -NoNewline; Format-StatusTag $p2DisplayStatus; Write-Host ''
if ($p2DisplayDone -and $p6Status -ne 'COMPLETE') { if ($p2DisplayDone -and $p6Status -ne 'COMPLETE') {
Write-Host ' >> Initiate ARTS Lockdown request' -ForegroundColor Yellow Write-Host ' >> READY: Initiate ARTS Lockdown request' -ForegroundColor Yellow
} }
Write-Host ' 3. Lockdown ' -NoNewline; Format-StatusTag $p6Status; Write-Host '' Write-Host ' 3. Lockdown ' -NoNewline; Format-StatusTag $p6Status; Write-Host ''
} }
@@ -791,6 +912,18 @@ function Invoke-SetupComplete {
Write-Warning "Failed to write completion marker: $_" Write-Warning "Failed to write completion marker: $_"
} }
# Final dashboard tick: idx=8 / status=succeeded. Marks the session
# 100% on /imaging. Belt-and-braces re-load of helper in case the
# main script's dot-source got lost across the DSC reboot.
Ensure-SendPxeStatus
if (Get-Command Send-PxeStatus -ErrorAction SilentlyContinue) {
try {
Send-PxeStatus -Stage 'Monitor-IntuneProgress: imaging complete' `
-StageIndex 8 -StageTotal 8 -Status 'succeeded' `
-IntuneDeviceId $script:cache.DeviceId
} catch { }
}
# Machine number prompt only (startup items are auto-applied by # Machine number prompt only (startup items are auto-applied by
# 06-OrganizeDesktop from the PC profile). Runs in SupportUser # 06-OrganizeDesktop from the PC profile). Runs in SupportUser
# session while tech is still near the PC; skipped silently if # session while tech is still near the PC; skipped silently if
@@ -862,6 +995,62 @@ function Invoke-RebootPrompt {
exit 0 exit 0
} }
# ============================================================================
# Post-PPKG settle mode: cancel pending shutdown, settle for N seconds while
# rendering live status, then perform a clean reboot. Persistent @logon
# sync_intune scheduled task takes over after reboot.
# ============================================================================
if ($PostPpkg) {
Write-Host ""
Write-Host "=== POST-PPKG SETTLE MODE ===" -ForegroundColor Cyan
Write-Host "Cancelling any pending PPKG-scheduled shutdown..."
cmd /c "shutdown /a 2>nul" | Out-Null
Start-Sleep -Seconds 1
# Aggressive sync triggering: hammer Schedule #3 every 30s during settle.
# Intune's natural sync interval is 8h baseline, dropping to ~3min after
# an explicit Schedule #3 trigger - but only if the device has done its
# first real sync. PPKG-fresh devices need a manual nudge before any
# automatic interval is even running. Forcing a trigger every 30s
# accelerates the 4 -> 5+ subkey transition that gates "ready for category".
$endTime = (Get-Date).AddSeconds($PostPpkgSettleSec)
$nextSyncTrigger = Get-Date
$syncInterval = [TimeSpan]::FromSeconds(30)
$triggerCount = 0
$earlyExit = $false
while ((Get-Date) -lt $endTime) {
$now = Get-Date
if ($now -ge $nextSyncTrigger) {
Invoke-IntuneSync
$triggerCount++
$nextSyncTrigger = $now.Add($syncInterval)
}
$remaining = [int]($endTime - $now).TotalSeconds
Clear-Host
$snap = Get-Snapshot
Format-Snapshot -Snap $snap -LastSync $now -NextRetrigger $endTime | Out-Null
Write-Host ""
Write-Host (" POST-PPKG SETTLE - rebooting in {0,3}s (Ctrl+C to abort)" -f $remaining) -ForegroundColor Yellow
Write-Host (" Sync triggers fired: {0} subkeys: {1}" -f $triggerCount, $snap.Phase1.PolicySubkeyCount)
if ($snap.Phase1.PoliciesBaselineReady) {
Write-Host " Subkeys past baseline (>=5) - settle complete, rebooting early." -ForegroundColor Green
$earlyExit = $true
break
}
Write-Host " Tech: do NOT touch the PC until reboot fires."
Start-Sleep -Seconds 5
}
Write-Host ""
Write-Host "Settle complete. Performing clean reboot..." -ForegroundColor Green
try { Stop-Transcript | Out-Null } catch {}
cmd /c "shutdown /a 2>nul" | Out-Null
Start-Sleep -Seconds 1
shutdown /r /t 5 /c "Post-PPKG settle complete - rebooting"
[Environment]::Exit(0)
}
# ============================================================================ # ============================================================================
# Main loop # Main loop
# #
@@ -888,7 +1077,7 @@ if ($AsTask -and (Test-Path -LiteralPath $syncCompleteMarker)) {
# For these types, enrollment is "done" once Phase 1 (Identity) completes # For these types, enrollment is "done" once Phase 1 (Identity) completes
# and policies start arriving - there is no Phase 2-5 to wait for. # and policies start arriving - there is no Phase 2-5 to wait for.
$pcTypeFile = 'C:\Enrollment\pc-type.txt' $pcTypeFile = 'C:\Enrollment\pc-type.txt'
$noDscTypes = @('Display') $noDscTypes = @('Display', 'gea-shopfloor-display')
$skipDsc = $false $skipDsc = $false
if (Test-Path $pcTypeFile) { if (Test-Path $pcTypeFile) {
$pcType = (Get-Content $pcTypeFile -First 1).Trim() $pcType = (Get-Content $pcTypeFile -First 1).Trim()

View File

@@ -15,6 +15,10 @@ function Send-PxeStatus {
[string]$Status = 'in_progress', [string]$Status = 'in_progress',
[string]$Error_ = '', [string]$Error_ = '',
[string[]]$LogLines = @(), [string[]]$LogLines = @(),
# Intune device ID (AAD/Entra device GUID from `dsregcmd /status`).
# Only available post-AAD-join; pass it from Monitor-IntuneProgress
# once captured. The dashboard renders a QR of this value.
[string]$IntuneDeviceId = '',
[string]$PxeServer = '10.9.100.1', [string]$PxeServer = '10.9.100.1',
[int]$Port = 9009, [int]$Port = 9009,
[int]$TimeoutSec = 5 [int]$TimeoutSec = 5
@@ -56,22 +60,33 @@ function Send-PxeStatus {
} }
if ($Error_) { $payload.error = $Error_ } if ($Error_) { $payload.error = $Error_ }
if ($LogLines) { $payload.log_lines = $LogLines } if ($LogLines) { $payload.log_lines = $LogLines }
if ($IntuneDeviceId) { $payload.intune_device_id = $IntuneDeviceId }
$body = $payload | ConvertTo-Json -Compress $body = $payload | ConvertTo-Json -Compress
$uri = "http://${PxeServer}:${Port}/imaging/status" $uri = "http://${PxeServer}:${Port}/imaging/status"
# Always log the attempt to C:\Logs\send-pxe-status.log so the operator
# can correlate dashboard state against actual outbound POSTs. Logs both
# success (one line per fired stage) and failure (with exception).
$logFile = 'C:\Logs\send-pxe-status.log'
try { try {
Invoke-WebRequest -Uri $uri -Method POST ` if (-not (Test-Path 'C:\Logs')) { New-Item -ItemType Directory -Path 'C:\Logs' -Force | Out-Null }
} catch { }
try {
$resp = Invoke-WebRequest -Uri $uri -Method POST `
-Body $body -ContentType 'application/json' ` -Body $body -ContentType 'application/json' `
-UseBasicParsing -TimeoutSec $TimeoutSec ` -UseBasicParsing -TimeoutSec $TimeoutSec `
-ErrorAction Stop | Out-Null -ErrorAction Stop
} catch {
# Never block imaging on a failed status push. Write to local log only.
try { try {
$logDir = 'C:\Logs' "$(Get-Date -Format s) OK idx=$StageIndex/$StageTotal status=$Status http=$($resp.StatusCode) stage='$Stage'" |
if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } Out-File -FilePath $logFile -Append -Encoding utf8
"$(Get-Date -Format s) Send-PxeStatus failed: $($_.Exception.Message)" |
Out-File -FilePath (Join-Path $logDir 'send-pxe-status.log') -Append -Encoding utf8
} catch { } } catch { }
} catch {
try {
"$(Get-Date -Format s) ERR idx=$StageIndex/$StageTotal status=$Status uri=$uri stage='$Stage' err=$($_.Exception.Message)" |
Out-File -FilePath $logFile -Append -Encoding utf8
} catch { }
# Never block imaging on a failed status push.
} }
} }