# 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: " 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 }