diff --git a/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 b/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 index 3be3156..3db4286 100644 --- a/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 @@ -72,7 +72,19 @@ param( [switch]$AsTask, # Path to Configure-PC.ps1, launched after post-reboot completion in # -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 ) # ============================================================================ @@ -168,10 +180,24 @@ function Format-Age { # revert once true, so we only re-check until they pass. # ============================================================================ $script:cache = @{ - AzureAdJoined = $false - IntuneEnrolled = $false - EmTaskExists = $false - EnrollmentId = $null + AzureAdJoined = $false + IntuneEnrolled = $false + EmTaskExists = $false + 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 { @@ -181,9 +207,29 @@ function Get-Phase1 { if ($dsreg -match 'AzureAdJoined\s*:\s*YES') { $script:cache.AzureAdJoined = $true } + # Capture DeviceId once available. Format from dsregcmd output: + # DeviceId : + # 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 {} } + # 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) { $eid = Get-EnrollmentId if ($eid) { @@ -200,18 +246,36 @@ function Get-Phase1 { } catch {} } - $policiesArriving = $false + # 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 + $policiesBaselineReady = $false try { $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 {} return @{ - AzureAdJoined = $script:cache.AzureAdJoined - IntuneEnrolled = $script:cache.IntuneEnrolled - EmTaskExists = $script:cache.EmTaskExists - PoliciesArriving = $policiesArriving - EnrollmentId = $script:cache.EnrollmentId + AzureAdJoined = $script:cache.AzureAdJoined + IntuneEnrolled = $script:cache.IntuneEnrolled + EmTaskExists = $script:cache.EmTaskExists + PoliciesArriving = $policiesArriving + PoliciesBaselineReady = $policiesBaselineReady + PolicySubkeyCount = $subkeyCount + EnrollmentId = $script:cache.EnrollmentId } } @@ -615,10 +679,48 @@ function Format-StatusTag { function Format-Snapshot { 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 += " GE Aerospace -- Shopfloor Device Setup" $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) { $lines += " Category: $($Snap.Function)" } @@ -628,13 +730,17 @@ function Format-Snapshot { Write-Host " ============================================" # 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 - $Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesArriving) + $Snap.Phase1.EmTaskExists -and $Snap.Phase1.PoliciesBaselineReady) $p1Status = Get-PhaseStatus @( - @{ Ok = $Snap.Phase1.AzureAdJoined; Failed = $false }, - @{ Ok = $Snap.Phase1.IntuneEnrolled; Failed = $false }, - @{ Ok = $Snap.Phase1.EmTaskExists; Failed = $false }, - @{ Ok = $Snap.Phase1.PoliciesArriving; Failed = $false } + @{ Ok = $Snap.Phase1.AzureAdJoined; Failed = $false }, + @{ Ok = $Snap.Phase1.IntuneEnrolled; Failed = $false }, + @{ Ok = $Snap.Phase1.EmTaskExists; Failed = $false }, + @{ Ok = $Snap.Phase1.PoliciesBaselineReady; Failed = $false } ) # Phase 6 / Lockdown (shared by both flows, rendered last). @@ -683,14 +789,25 @@ function Format-Snapshot { # Render 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) { - 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 ' 3. Software Deployment ' -NoNewline; Format-StatusTag $p3Status; Write-Host '' Write-Host ' 4. Credential Setup ' -NoNewline; Format-StatusTag $p4Status; Write-Host '' - if ($p4Done -and $p6Status -ne 'COMPLETE') { - Write-Host ' >> Initiate ARTS Lockdown request' -ForegroundColor Yellow + # Mid-flow reboot prompt: DSC deployment finished, install phase requires + # 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 '' } else { @@ -703,12 +820,16 @@ function Format-Snapshot { else { 'WAITING' } 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) { - 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 '' 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 '' } @@ -791,6 +912,18 @@ function Invoke-SetupComplete { 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 # 06-OrganizeDesktop from the PC profile). Runs in SupportUser # session while tech is still near the PC; skipped silently if @@ -862,6 +995,62 @@ function Invoke-RebootPrompt { 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 # @@ -888,7 +1077,7 @@ if ($AsTask -and (Test-Path -LiteralPath $syncCompleteMarker)) { # For these types, enrollment is "done" once Phase 1 (Identity) completes # and policies start arriving - there is no Phase 2-5 to wait for. $pcTypeFile = 'C:\Enrollment\pc-type.txt' -$noDscTypes = @('Display') +$noDscTypes = @('Display', 'gea-shopfloor-display') $skipDsc = $false if (Test-Path $pcTypeFile) { $pcType = (Get-Content $pcTypeFile -First 1).Trim() diff --git a/playbook/shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 b/playbook/shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 index cd9b284..461ef6f 100644 --- a/playbook/shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/lib/Send-PxeStatus.ps1 @@ -15,6 +15,10 @@ function Send-PxeStatus { [string]$Status = 'in_progress', [string]$Error_ = '', [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', [int]$Port = 9009, [int]$TimeoutSec = 5 @@ -54,24 +58,35 @@ function Send-PxeStatus { stage_total = $StageTotal status = $Status } - if ($Error_) { $payload.error = $Error_ } - if ($LogLines) { $payload.log_lines = $LogLines } + if ($Error_) { $payload.error = $Error_ } + if ($LogLines) { $payload.log_lines = $LogLines } + if ($IntuneDeviceId) { $payload.intune_device_id = $IntuneDeviceId } $body = $payload | ConvertTo-Json -Compress $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 { - 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' ` -UseBasicParsing -TimeoutSec $TimeoutSec ` - -ErrorAction Stop | Out-Null - } catch { - # Never block imaging on a failed status push. Write to local log only. + -ErrorAction Stop try { - $logDir = 'C:\Logs' - if (-not (Test-Path $logDir)) { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } - "$(Get-Date -Format s) Send-PxeStatus failed: $($_.Exception.Message)" | - Out-File -FilePath (Join-Path $logDir 'send-pxe-status.log') -Append -Encoding utf8 + "$(Get-Date -Format s) OK idx=$StageIndex/$StageTotal status=$Status http=$($resp.StatusCode) stage='$Stage'" | + Out-File -FilePath $logFile -Append -Encoding utf8 } 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. } }