Files
pxe-server/playbook/shopfloor-setup/Shopfloor/Monitor-IntuneProgress.ps1
cproudlock 66d13d8ad0 sync_intune: rewrite as 5-phase status monitor with reboot detection
Replaces the 3-step pass/fail polling that lived in the .bat with a
PowerShell monitor that renders a full status table for the SFLD
enrollment lifecycle and handles the pre-reboot -> reboot -> post-reboot
transition explicitly.

Three structural problems with the old script:

1. Step 3 ("SFLD - Consume Credentials task exists") fired too early.
   The task is created by SetupCredentials.log around 08:52 in the
   pre-reboot phase, NOT post-reboot, so passing all 3 gates didn't
   actually mean "fully done" - it just meant "credential setup ran".

2. No detection of the pre-reboot -> reboot -> post-reboot transition.
   The script never read DSCDeployment.log, so it couldn't tell the
   user "you need to reboot now to start the install phase". A device
   stuck waiting for reboot was indistinguishable from one still
   syncing.

3. No visibility into Phase 4 (per-script wrappers like Install-eDNC,
   Install-UDC, Install-VCRedists, Install-OpenText). When something
   hung you had to manually grep C:\Logs\SFLD\.

New layout:

  sync_intune.bat - thin launcher (~50 lines): self-elevate, invoke
                    Monitor-IntuneProgress.ps1, branch on exit code
                    (0 = done / 2 = reboot needed / else = error).

  Monitor-IntuneProgress.ps1 - the actual monitor (~340 lines):
    - 5-phase status table (Identity / SFLD config / DSC deployment +
      install / Custom scripts / Final) updated every 30s via Clear-
      Host + redraw, with the QR code anchored at the top.
    - Phase 4 auto-discovers custom scripts by parsing DSCInstall.log
      for "Downloading script: <name>" lines AND scanning C:\Logs\SFLD\
      Install-*.log files - so Display PCs running entirely different
      scripts surface their own list automatically without hardcoding.
      Statuses: pending / running / done / failed (mtime + tail-based).
    - Boot-loop-safe reboot detection via Test-RebootState: only signals
      'needed' if DSCDeployment.log was modified AFTER LastBootUpTime.
      Once we've rebooted past it, just waits for DSCInstall.log.
    - Caches monotonic Phase 1 indicators (AzureAdJoined, IntuneEnrolled,
      EnterpriseMgmt task) so dsregcmd /status (slow ~1-2s) only runs
      until the flag flips true, not on every poll.
    - Triggers Intune sync at startup, re-triggers every 3 minutes (was
      every 15 seconds in the old loop, which actively interrupted
      in-flight CSP work).

  Exit codes consumed by sync_intune.bat:
    0 - DSCInstall.log shows "Installation completed successfully"
    2 - DSCDeployment.log shows "Deployment completed successfully" AND
        the deploy log is newer than LastBootUpTime (= reboot needed)
    1 - error

Detection markers (decoded from a captured run at /home/camp/pxe-images/
Logs/ - see comment block at top of Monitor-IntuneProgress.ps1).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-09 10:56:20 -04:00

454 lines
18 KiB
PowerShell

# Monitor-IntuneProgress.ps1 - Real-time status display for the SFLD enrollment
# lifecycle. Called from sync_intune.bat after admin elevation. Replaces the
# 3-step pass/fail polling that lived in the .bat with a richer status table
# spanning all 5 phases of the lifecycle.
#
# WHY THIS EXISTS:
# The previous sync_intune.bat checked 3 things sequentially (SFLD reg key,
# DSCInstall.log "Installation completed successfully", "SFLD - Consume
# Credentials" task). Three problems with that:
# 1. The Consume Credentials task is created PRE-reboot in
# SetupCredentials.log, not POST-reboot, so it isn't a "fully done"
# signal - it's a pre-reboot signal that overlaps with the SFLD reg key.
# 2. There was no detection of the pre-reboot -> reboot -> post-reboot
# transition. The script never read DSCDeployment.log, so it couldn't
# tell the user "you need to reboot now".
# 3. No visibility into Phase 4 (per-script wrappers like Install-eDNC,
# Install-UDC, Install-VCRedists, Install-OpenText). When something hung
# you had to manually grep C:\Logs\SFLD\.
#
# WHAT THIS SCRIPT DOES:
# - Renders a 5-phase status table that updates every $PollSecs (default 30s)
# - Triggers an Intune sync at startup, re-triggers every $RetriggerMinutes
# (default 3 min) until the device hits the final state
# - Auto-discovers Phase 4 custom scripts by parsing "Downloading script:"
# lines from DSCInstall.log AND scanning C:\Logs\SFLD\Install-*.log files
# - Boot-loop-safe reboot detection: only signals "reboot needed" if
# DSCDeployment.log was modified AFTER the last system boot
# - Caches dsregcmd output and other monotonic Phase 1 indicators (once
# true, they don't go back to false)
#
# EXIT CODES (consumed by sync_intune.bat):
# 0 = all done, post-reboot install complete, no further action needed
# 2 = reboot required (deployment phase done, install phase pending)
# 1 = error
#
# DETECTION REFERENCES (decoded from a real run captured at /home/camp/pxe-images/Logs/):
# Phase A (pre-reboot, ~08:35-08:52):
# - enrollment.log ppkg + computer name
# - SetupCredentials.log creates HKLM\SOFTWARE\GE\SFLD\DSC + Consume Credentials task
# - DSCDeployment.log ends "Deployment completed successfully"
# writes C:\Logs\SFLD\version.txt
# creates SFLD-ApplyDSCConfig scheduled task
# [REBOOT]
# Phase B (post-reboot, ~08:58):
# - DSCInstall.log downloads device-config.yaml from blob,
# processes Applications + CustomScripts,
# ends "Installation completed successfully"
# writes C:\Logs\SFLD\DSCVersion.txt
# - C:\Logs\SFLD\Install-*.log one per CustomScript wrapper
[CmdletBinding()]
param(
[int]$PollSecs = 30,
[int]$RetriggerMinutes = 3
)
# ============================================================================
# Helpers
# ============================================================================
function Read-RegValue {
param([string]$Path, [string]$Name)
try { (Get-ItemProperty -Path $Path -Name $Name -ErrorAction Stop).$Name }
catch { $null }
}
function Get-EnrollmentId {
Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\Enrollments' -ErrorAction SilentlyContinue |
Where-Object { (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).ProviderID -eq 'MS DM Server' } |
Select-Object -First 1 -ExpandProperty PSChildName
}
function Format-Age {
param($Seconds)
if ($null -eq $Seconds) { return "??" }
$s = [double]$Seconds
if ($s -lt 0) { return "now" }
if ($s -lt 60) { return "{0:N0}s" -f $s }
if ($s -lt 3600) { return "{0:N0}m {1:N0}s" -f [math]::Floor($s / 60), ($s % 60) }
return "{0:N1}h" -f ($s / 3600)
}
# ============================================================================
# Monotonic Phase 1 cache - dsregcmd is slow (~1-2s) and these flags don't
# revert once true, so we only re-check until they pass.
# ============================================================================
$script:cache = @{
AzureAdJoined = $false
IntuneEnrolled = $false
EmTaskExists = $false
EnrollmentId = $null
}
function Get-Phase1 {
if (-not $script:cache.AzureAdJoined) {
try {
$dsreg = dsregcmd /status 2>&1
if ($dsreg -match 'AzureAdJoined\s*:\s*YES') {
$script:cache.AzureAdJoined = $true
}
} catch {}
}
if (-not $script:cache.IntuneEnrolled) {
$eid = Get-EnrollmentId
if ($eid) {
$script:cache.EnrollmentId = $eid
$script:cache.IntuneEnrolled = $true
}
}
if (-not $script:cache.EmTaskExists -and $script:cache.EnrollmentId) {
try {
$tp = "\Microsoft\Windows\EnterpriseMgmt\$($script:cache.EnrollmentId)\"
$tasks = Get-ScheduledTask -TaskPath $tp -ErrorAction SilentlyContinue
if ($tasks) { $script:cache.EmTaskExists = $true }
} catch {}
}
$policiesArriving = $false
try {
$children = Get-ChildItem 'HKLM:\SOFTWARE\Microsoft\PolicyManager\current\device' -ErrorAction SilentlyContinue
$policiesArriving = (($children | Measure-Object).Count -gt 0)
} catch {}
return @{
AzureAdJoined = $script:cache.AzureAdJoined
IntuneEnrolled = $script:cache.IntuneEnrolled
EmTaskExists = $script:cache.EmTaskExists
PoliciesArriving = $policiesArriving
EnrollmentId = $script:cache.EnrollmentId
}
}
# ============================================================================
# Phase 4: auto-discover custom scripts. Parse DSCInstall.log for
# "Downloading script: <name>" lines to know what's EXPECTED, then look at
# C:\Logs\SFLD\Install-*.log to know what's DONE/RUNNING/FAILED. Anything
# expected but missing a log file is "pending".
# ============================================================================
function Get-CustomScriptStatuses {
param([string]$DscInstallLog)
$logDir = 'C:\Logs\SFLD'
if (-not (Test-Path $logDir)) { return @() }
# Discover existing Install-*.log files
$now = Get-Date
$existing = @{}
Get-ChildItem $logDir -Filter 'Install-*.log' -ErrorAction SilentlyContinue | ForEach-Object {
$age = ($now - $_.LastWriteTime).TotalSeconds
$tail = Get-Content $_.FullName -Tail 30 -ErrorAction SilentlyContinue
$hasError = $false
if ($tail) {
$errMatch = $tail | Where-Object { $_ -match '(?i)\b(ERROR|Failed|exception)\b' } | Select-Object -First 1
if ($errMatch) { $hasError = $true }
}
$status = if ($age -lt 30) { 'running' }
elseif ($hasError) { 'failed' }
else { 'done' }
$existing[$_.BaseName] = @{
Name = $_.BaseName
Status = $status
Age = $age
LogFile = $_.FullName
}
}
# Parse DSCInstall.log for the expected script list
$expected = @()
if (Test-Path $DscInstallLog) {
try {
$hits = Select-String -Path $DscInstallLog -Pattern 'Downloading script:\s*(\S+)' -ErrorAction SilentlyContinue
foreach ($h in $hits) {
$name = $h.Matches[0].Groups[1].Value
if ($name -notlike 'Install-*') { $name = "Install-$name" }
if ($expected -notcontains $name) { $expected += $name }
}
} catch {}
}
# Build final ordered list: expected scripts first (in DSC order), then any
# log files we found that weren't in the expected list (legacy / orphaned).
$result = @()
foreach ($name in $expected) {
if ($existing.ContainsKey($name)) {
$result += $existing[$name]
} else {
$result += @{ Name = $name; Status = 'pending'; Age = $null; LogFile = $null }
}
}
foreach ($name in $existing.Keys) {
if ($expected -notcontains $name) {
$result += $existing[$name]
}
}
return $result
}
# ============================================================================
# Boot-loop-safe reboot check.
# Returns:
# 'none' - install already complete OR pre-reboot phase not done
# 'needed' - pre-reboot done AND we haven't rebooted since
# 'in-progress' - pre-reboot done AND we already rebooted (just waiting for
# post-reboot DSCInstall.log to finish)
# ============================================================================
function Test-RebootState {
$deployLog = 'C:\Logs\SFLD\DSCDeployment.log'
$installLog = 'C:\Logs\SFLD\DSCInstall.log'
if (Test-Path $installLog) {
$tail = Get-Content $installLog -Tail 10 -ErrorAction SilentlyContinue
if ($tail -match 'Installation completed successfully') { return 'none' }
}
if (Test-Path $deployLog) {
$tail = Get-Content $deployLog -Tail 10 -ErrorAction SilentlyContinue
if ($tail -match 'Deployment completed successfully') {
try {
$lastBoot = (Get-CimInstance Win32_OperatingSystem -ErrorAction Stop).LastBootUpTime
$deployTime = (Get-Item $deployLog -ErrorAction Stop).LastWriteTime
if ($deployTime -gt $lastBoot) { return 'needed' }
return 'in-progress'
} catch {
return 'needed'
}
}
}
return 'none'
}
# ============================================================================
# Snapshot - one call collects everything we need to render
# ============================================================================
function Get-Snapshot {
$p1 = Get-Phase1
$function = Read-RegValue 'HKLM:\SOFTWARE\GE\SFLD\DSC' 'Function'
$sasToken = Read-RegValue 'HKLM:\SOFTWARE\GE\SFLD\DSC' 'SasToken'
$deployLog = 'C:\Logs\SFLD\DSCDeployment.log'
$installLog = 'C:\Logs\SFLD\DSCInstall.log'
$deployExists = Test-Path $deployLog
$deployComplete = $false
if ($deployExists) {
$t = Get-Content $deployLog -Tail 10 -ErrorAction SilentlyContinue
if ($t -match 'Deployment completed successfully') { $deployComplete = $true }
}
$installExists = Test-Path $installLog
$installComplete = $false
if ($installExists) {
$t = Get-Content $installLog -Tail 10 -ErrorAction SilentlyContinue
if ($t -match 'Installation completed successfully') { $installComplete = $true }
}
$consumeCredsTask = $false
try {
$task = Get-ScheduledTask -TaskName 'SFLD - Consume Credentials' -ErrorAction SilentlyContinue
if ($task) { $consumeCredsTask = $true }
} catch {}
$customScripts = Get-CustomScriptStatuses -DscInstallLog $installLog
return [PSCustomObject]@{
Function = $function
Phase1 = $p1
Phase2 = @{
SfldRoot = (Test-Path 'HKLM:\SOFTWARE\GE\SFLD')
FunctionOk = (-not [string]::IsNullOrWhiteSpace($function))
SasTokenOk = (-not [string]::IsNullOrWhiteSpace($sasToken))
}
Phase3 = @{
DeployLogExists = $deployExists
DeployComplete = $deployComplete
InstallLogExists = $installExists
InstallComplete = $installComplete
}
Phase4 = $customScripts
Phase5 = @{
ConsumeCredsTask = $consumeCredsTask
}
DscInstallComplete = $installComplete
}
}
# ============================================================================
# Sync trigger (Schedule #3 task per Intune enrollment)
# ============================================================================
function Invoke-IntuneSync {
try {
$enrollPath = 'HKLM:\SOFTWARE\Microsoft\Enrollments'
Get-ChildItem $enrollPath -ErrorAction SilentlyContinue | ForEach-Object {
$provider = (Get-ItemProperty $_.PSPath -ErrorAction SilentlyContinue).ProviderID
if ($provider -eq 'MS DM Server') {
$id = $_.PSChildName
$tp = "\Microsoft\Windows\EnterpriseMgmt\$id\"
Get-ScheduledTask -TaskPath $tp -ErrorAction SilentlyContinue |
Where-Object { $_.TaskName -match 'Schedule #3' } |
ForEach-Object { Start-ScheduledTask -InputObject $_ -ErrorAction SilentlyContinue }
}
}
} catch {}
}
# ============================================================================
# QR code (cached as text - generate once, re-print on every redraw)
# ============================================================================
function Build-QRCodeText {
$lines = @()
try {
$dsreg = dsregcmd /status 2>&1
$line = $dsreg | Select-String 'DeviceId' | Select-Object -First 1
} catch {
$line = $null
}
if (-not $line) {
$lines += "Device not yet Azure AD joined."
return ($lines -join "`n")
}
$deviceId = $line.ToString().Split(':')[1].Trim()
$lines += "Intune Device ID: $deviceId"
$lines += ""
$dllPath = 'C:\Enrollment\shopfloor-setup\Shopfloor\QRCoder.dll'
if (Test-Path $dllPath) {
try {
Add-Type -Path $dllPath
$gen = New-Object QRCoder.QRCodeGenerator
$data = $gen.CreateQrCode($deviceId, [QRCoder.QRCodeGenerator+ECCLevel]::L)
$ascii = New-Object QRCoder.AsciiQRCode($data)
$qr = $ascii.GetGraphic(1, [char]0x2588 + [char]0x2588, ' ')
$lines += $qr -split "`r?`n"
} catch {
$lines += "(QR code generation failed: $($_.Exception.Message))"
}
} else {
$lines += "(QRCoder.dll not found at $dllPath - skipping QR code)"
}
return ($lines -join "`n")
}
# ============================================================================
# Renderer
# ============================================================================
function Format-Snapshot {
param($Snap, $LastSync, $NextRetrigger)
function Mk { param([bool]$ok) if ($ok) { '[v]' } else { '[ ]' } }
$lines = @()
$lines += "========================================"
$lines += " Intune Lockdown Progress"
if ($Snap.Function) {
$lines += " Function: $($Snap.Function)"
}
$lines += "========================================"
$lines += ""
$lines += " Phase 1: Identity"
$lines += " $(Mk $Snap.Phase1.AzureAdJoined) Azure AD joined"
$lines += " $(Mk $Snap.Phase1.IntuneEnrolled) Intune enrolled"
$lines += " $(Mk $Snap.Phase1.EmTaskExists) EnterpriseMgmt sync tasks"
$lines += " $(Mk $Snap.Phase1.PoliciesArriving) Policies arriving"
$lines += ""
$lines += " Phase 2: SFLD configuration"
$lines += " $(Mk $Snap.Phase2.SfldRoot) SFLD reg key"
$lines += " $(Mk $Snap.Phase2.FunctionOk) Function set"
$lines += " $(Mk $Snap.Phase2.SasTokenOk) DSC SAS token configured"
$lines += ""
$lines += " Phase 3: DSC deployment + install"
$lines += " $(Mk $Snap.Phase3.DeployLogExists) DSCDeployment.log present"
$lines += " $(Mk $Snap.Phase3.DeployComplete) Pre-reboot deployment complete"
$lines += " $(Mk $Snap.Phase3.InstallLogExists) DSCInstall.log present"
$lines += " $(Mk $Snap.Phase3.InstallComplete) Post-reboot install complete"
$lines += ""
$lines += " Phase 4: Custom scripts (auto-discovered)"
if (-not $Snap.Phase4 -or $Snap.Phase4.Count -eq 0) {
$lines += " (no Install-*.log files yet in C:\Logs\SFLD)"
} else {
foreach ($s in $Snap.Phase4) {
$mark = switch ($s.Status) {
'done' { '[v]' }
'running' { '[.]' }
'failed' { '[!]' }
'pending' { '[ ]' }
default { '[?]' }
}
$detail = switch ($s.Status) {
'done' { "done $(Format-Age $s.Age) ago" }
'running' { "running, last update $(Format-Age $s.Age) ago" }
'failed' { "FAILED $(Format-Age $s.Age) ago" }
'pending' { "pending" }
default { "" }
}
$name = $s.Name.PadRight(22)
$lines += " $mark $name $detail"
}
}
$lines += ""
$lines += " Phase 5: Final"
$lines += " $(Mk $Snap.Phase5.ConsumeCredsTask) SFLD - Consume Credentials task"
$lines += ""
$sinceSync = ((Get-Date) - $LastSync).TotalSeconds
$untilNext = ($NextRetrigger - (Get-Date)).TotalSeconds
$lines += " Sync: triggered $(Format-Age $sinceSync) ago | next re-trigger in $(Format-Age $untilNext)"
return $lines
}
# ============================================================================
# Main loop
# ============================================================================
$qrText = Build-QRCodeText
Invoke-IntuneSync
$lastSync = Get-Date
$nextRetrigger = $lastSync.AddMinutes($RetriggerMinutes)
while ($true) {
$snap = Get-Snapshot
Clear-Host
Write-Host $qrText
Write-Host ""
foreach ($l in (Format-Snapshot -Snap $snap -LastSync $lastSync -NextRetrigger $nextRetrigger)) {
Write-Host $l
}
# Final state: post-reboot install complete -> exit clean
if ($snap.DscInstallComplete) {
Write-Host ""
Write-Host "All milestones reached. Setup complete." -ForegroundColor Green
exit 0
}
# Reboot check (boot-loop-safe)
$rebootState = Test-RebootState
if ($rebootState -eq 'needed') {
Write-Host ""
Write-Host "Pre-reboot deployment phase complete - REBOOT REQUIRED" -ForegroundColor Yellow
exit 2
}
# Re-trigger sync periodically
if ((Get-Date) -ge $nextRetrigger) {
Write-Host ""
Write-Host "Re-triggering Intune sync..." -ForegroundColor Cyan
Invoke-IntuneSync
$lastSync = Get-Date
$nextRetrigger = $lastSync.AddMinutes($RetriggerMinutes)
}
Start-Sleep -Seconds $PollSecs
}