From b13e34c05a5b321800ea663c92e3ff4a3537daac Mon Sep 17 00:00:00 2001 From: cproudlock Date: Fri, 10 Apr 2026 09:55:00 -0400 Subject: [PATCH] Imaging chain: Stage-Dispatcher + PPKG reboot + unattended sync_intune Replaces the single-session "cancel PPKG reboot and cram everything into one autologon" flow with a staged chain where each reboot advances to the next step automatically. The technician touches the keyboard 3 times total (UNPLUG prompt, Y to reboot, Configure-PC selections). New Stage-Dispatcher.ps1: Reads C:\Enrollment\setup-stage.txt and chains through: shopfloor-setup -> sync-intune -> configure-pc Each stage re-registers HKLM RunOnce so the dispatcher fires again on the next logon. Stage file is deleted when the chain completes. Transcript logged to C:\Logs\SFLD\stage-dispatcher.log. Stage "shopfloor-setup": runs Run-ShopfloorSetup.ps1 (which reboots via shutdown /r /t 10). Dispatcher advances stage to sync-intune in the ~10 second window before the machine goes down, re-registers RunOnce. Stage "sync-intune": launches Monitor-IntuneProgress.ps1 -Unattended. Exit 2 (pre-reboot done, user confirmed): dispatcher re-registers RunOnce and initiates shutdown /r /t 5. Stage stays at sync-intune so the monitor picks up post-reboot state on next boot. Exit 0 (post-reboot install complete): dispatcher chains directly to Configure-PC.ps1 in the same session, then deletes the stage file. Stage "configure-pc": runs Configure-PC.ps1 and deletes the stage file. Fallback entry point if the post-reboot chain was interrupted. Modified run-enrollment.ps1: Removed the shutdown /a that canceled the PPKG reboot. Instead writes setup-stage.txt = "shopfloor-setup" and registers RunOnce for the dispatcher. PPKG reboot fires naturally (handles PendingFileRename operations like Zscaler rename and PPKG self-cleanup). Now tracked in the git repo at playbook/shopfloor-setup/run-enrollment.ps1. Modified Monitor-IntuneProgress.ps1: New -Unattended switch. When set: Invoke-SetupComplete exits 0 without waiting for keypress. Invoke-RebootPrompt exits 2 without prompting or rebooting (dispatcher handles both). Manual sync_intune.bat usage (no flag) unchanged. RetriggerMinutes bumped from 3 to 5 (user request). Modified startnet.cmd: Now also copies Stage-Dispatcher.ps1 from the PXE server to W:\Enrollment\Stage-Dispatcher.ps1 alongside run-enrollment.ps1. Co-Authored-By: Claude Opus 4.6 (1M context) --- .../Shopfloor/lib/Monitor-IntuneProgress.ps1 | 22 ++- playbook/shopfloor-setup/Stage-Dispatcher.ps1 | 175 ++++++++++++++++++ playbook/shopfloor-setup/run-enrollment.ps1 | 78 ++++++++ playbook/startnet.cmd | 1 + 4 files changed, 275 insertions(+), 1 deletion(-) create mode 100644 playbook/shopfloor-setup/Stage-Dispatcher.ps1 create mode 100755 playbook/shopfloor-setup/run-enrollment.ps1 diff --git a/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 b/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 index 17eccf5..c0ffe24 100644 --- a/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 +++ b/playbook/shopfloor-setup/Shopfloor/lib/Monitor-IntuneProgress.ps1 @@ -51,7 +51,13 @@ [CmdletBinding()] param( [int]$PollSecs = 30, - [int]$RetriggerMinutes = 3 + [int]$RetriggerMinutes = 5, + # When set, the monitor exits with a status code instead of prompting + # or rebooting. Used by Stage-Dispatcher.ps1 to keep control of the + # imaging chain. Manual sync_intune.bat usage (no flag) stays interactive. + # exit 0 = post-reboot install complete, all milestones reached + # exit 2 = pre-reboot deployment done, reboot needed + [switch]$Unattended ) # ============================================================================ @@ -547,6 +553,11 @@ function Invoke-SetupComplete { Write-Host "========================================" -ForegroundColor Green Write-Host "" Write-Host "The post-reboot DSC install phase is finished. The device is ready." + + if ($Unattended) { + Write-Host "(Unattended mode - returning to dispatcher)" + exit 0 + } Wait-ForAnyKey exit 0 } @@ -562,6 +573,15 @@ function Invoke-RebootPrompt { Write-Host "device-config.yaml and runs the per-app wrappers: Install-eDNC," Write-Host "Install-UDC, Install-VCRedists, Install-OpenText, and so on." Write-Host "" + + if ($Unattended) { + # Dispatcher mode: exit with code 2 so the dispatcher can set + # RunOnce before initiating the reboot itself. + Write-Host "Pre-reboot complete. Returning to dispatcher (exit 2)." -ForegroundColor Yellow + exit 2 + } + + # Interactive mode (manual sync_intune.bat): prompt and reboot directly. Write-Host "Press Y to reboot now, or N to cancel: " -NoNewline -ForegroundColor Cyan $ans = Read-SingleKey -ValidKeys @('Y', 'N') Write-Host $ans diff --git a/playbook/shopfloor-setup/Stage-Dispatcher.ps1 b/playbook/shopfloor-setup/Stage-Dispatcher.ps1 new file mode 100644 index 0000000..e759843 --- /dev/null +++ b/playbook/shopfloor-setup/Stage-Dispatcher.ps1 @@ -0,0 +1,175 @@ +# Stage-Dispatcher.ps1 - Chain controller for the shopfloor imaging pipeline. +# +# Reads C:\Enrollment\setup-stage.txt to know which step of the imaging +# process we're in, launches the right script, advances the stage, and +# re-registers itself via RunOnce so the next logon picks up the next step. +# +# STAGE CHAIN: +# "shopfloor-setup" -> Run-ShopfloorSetup.ps1 (installs apps, deferred org) +# Run-ShopfloorSetup reboots via shutdown /r /t 10; +# dispatcher has ~10 seconds to write the next stage +# and re-register RunOnce before the machine goes down. +# +# "sync-intune" -> Monitor-IntuneProgress.ps1 -Unattended +# Monitor exits 2 = pre-reboot Y pressed (no reboot yet) +# -> dispatcher re-registers RunOnce, initiates reboot +# Monitor exits 0 = post-reboot install complete +# -> dispatcher advances to configure-pc +# +# "configure-pc" -> Configure-PC.ps1 (startup items, machine number) +# -> dispatcher deletes stage file. Chain complete. +# +# (file missing) -> exit 0. Setup is done, nothing to do. +# +# ENTRY POINT: +# Registered as a RunOnce value by run-enrollment.ps1 after PPKG install. +# Each stage re-registers RunOnce for the next boot. RunOnce entries are +# consumed by Windows after execution, so no cleanup needed. +# +# LOGGING: +# Appends to C:\Logs\SFLD\stage-dispatcher.log via Start-Transcript. + +$ErrorActionPreference = 'Continue' + +# --- Transcript --- +$logDir = 'C:\Logs\SFLD' +if (-not (Test-Path $logDir)) { + try { New-Item -ItemType Directory -Path $logDir -Force | Out-Null } catch { $logDir = $env:TEMP } +} +$transcriptPath = Join-Path $logDir 'stage-dispatcher.log' +try { Start-Transcript -Path $transcriptPath -Append -Force | Out-Null } catch {} + +Write-Host "" +Write-Host "================================================================" +Write-Host "=== Stage-Dispatcher.ps1 $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ===" +Write-Host "================================================================" +Write-Host "Running as: $([Security.Principal.WindowsIdentity]::GetCurrent().Name)" + +# --- Paths --- +$stageFile = 'C:\Enrollment\setup-stage.txt' +$dispatcherPath = $MyInvocation.MyCommand.Path +$enrollDir = 'C:\Enrollment' +$shopfloorDir = Join-Path $enrollDir 'shopfloor-setup\Shopfloor' +$runOnceKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce' + +# --- Stage file --- +if (-not (Test-Path -LiteralPath $stageFile)) { + Write-Host "No stage file found at $stageFile - setup complete, nothing to do." + try { Stop-Transcript | Out-Null } catch {} + exit 0 +} + +$stage = (Get-Content -LiteralPath $stageFile -First 1).Trim() +Write-Host "Current stage: $stage" + +# --- Helper: re-register this dispatcher to run at next logon --- +function Register-NextRun { + try { + Set-ItemProperty -Path $runOnceKey -Name 'ShopfloorSetup' ` + -Value "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$dispatcherPath`"" ` + -Type String -Force -ErrorAction Stop + Write-Host "RunOnce registered for next logon." + } catch { + Write-Warning "Failed to register RunOnce: $_" + } +} + +# --- Stage dispatch --- +switch ($stage) { + + 'shopfloor-setup' { + Write-Host "" + Write-Host "--- Stage: shopfloor-setup ---" + $script = Join-Path $enrollDir 'Run-ShopfloorSetup.ps1' + if (-not (Test-Path -LiteralPath $script)) { + Write-Warning "Run-ShopfloorSetup.ps1 not found at $script" + break + } + + # Run-ShopfloorSetup.ps1 calls shutdown /r /t 10 at the end, which + # gives us a ~10 second window after it returns to advance the stage + # and re-register RunOnce before the reboot fires. + & $script + + Write-Host "Run-ShopfloorSetup.ps1 finished. Advancing stage to sync-intune." + Set-Content -LiteralPath $stageFile -Value 'sync-intune' -Force + Register-NextRun + Write-Host "Reboot imminent (initiated by Run-ShopfloorSetup.ps1)." + } + + 'sync-intune' { + Write-Host "" + Write-Host "--- Stage: sync-intune ---" + $monitor = Join-Path $shopfloorDir 'lib\Monitor-IntuneProgress.ps1' + if (-not (Test-Path -LiteralPath $monitor)) { + Write-Warning "Monitor-IntuneProgress.ps1 not found at $monitor" + break + } + + # Launch the monitor in THIS console window (interactive - tech sees + # QR code + status table). -Unattended makes it exit with a status + # code instead of prompting/rebooting, so we keep control here. + Write-Host "Launching Intune sync monitor (unattended mode)..." + & $monitor -Unattended + $monitorExit = $LASTEXITCODE + Write-Host "Monitor exited with code: $monitorExit" + + switch ($monitorExit) { + 0 { + # Post-reboot install complete. All milestones reached. + # Chain directly to Configure-PC (same session, no reboot). + Write-Host "Post-reboot install complete. Launching Configure-PC..." + Set-Content -LiteralPath $stageFile -Value 'configure-pc' -Force + + $configScript = Join-Path $shopfloorDir 'Configure-PC.ps1' + if (Test-Path -LiteralPath $configScript) { + & $configScript + } else { + Write-Warning "Configure-PC.ps1 not found at $configScript" + } + + # Chain complete. + Write-Host "Imaging chain complete. Removing stage file." + Remove-Item -LiteralPath $stageFile -Force -ErrorAction SilentlyContinue + } + 2 { + # Pre-reboot deployment done. User confirmed reboot. + # Re-register RunOnce so we come back after the reboot to + # monitor the post-reboot install phase. + Write-Host "Pre-reboot phase complete. Rebooting..." + Register-NextRun + # Stage stays at 'sync-intune' - on next boot the monitor + # will detect post-reboot state and monitor until done. + shutdown /r /t 5 + } + default { + Write-Warning "Unexpected monitor exit code: $monitorExit" + Write-Host "Re-registering for retry on next logon." + Register-NextRun + } + } + } + + 'configure-pc' { + Write-Host "" + Write-Host "--- Stage: configure-pc ---" + $configScript = Join-Path $shopfloorDir 'Configure-PC.ps1' + if (Test-Path -LiteralPath $configScript) { + & $configScript + } else { + Write-Warning "Configure-PC.ps1 not found at $configScript" + } + + Write-Host "Imaging chain complete. Removing stage file." + Remove-Item -LiteralPath $stageFile -Force -ErrorAction SilentlyContinue + } + + default { + Write-Warning "Unknown stage: '$stage'. Removing stage file." + Remove-Item -LiteralPath $stageFile -Force -ErrorAction SilentlyContinue + } +} + +Write-Host "" +Write-Host "=== Stage-Dispatcher.ps1 finished $(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') ===" +try { Stop-Transcript | Out-Null } catch {} diff --git a/playbook/shopfloor-setup/run-enrollment.ps1 b/playbook/shopfloor-setup/run-enrollment.ps1 new file mode 100755 index 0000000..a38b940 --- /dev/null +++ b/playbook/shopfloor-setup/run-enrollment.ps1 @@ -0,0 +1,78 @@ +# run-enrollment.ps1 +# Installs GCCH enrollment provisioning package via Install-ProvisioningPackage +# Called by FirstLogonCommands as SupportUser (admin) after imaging + +$ErrorActionPreference = 'Continue' +$logFile = "C:\Logs\enrollment.log" +New-Item -ItemType Directory -Path "C:\Logs" -Force -ErrorAction SilentlyContinue | Out-Null + +function Log { + param([string]$Message) + $ts = Get-Date -Format "yyyy-MM-dd HH:mm:ss" + $line = "$ts $Message" + Write-Host $line + Add-Content -Path $logFile -Value $line +} + +Log "=== GE Aerospace GCCH Enrollment ===" + +# --- Find the .ppkg --- +$ppkgFile = Get-ChildItem "C:\Enrollment\*.ppkg" -ErrorAction SilentlyContinue | Select-Object -First 1 +if (-not $ppkgFile) { + Log "ERROR: No .ppkg found in C:\Enrollment\" + exit 1 +} +Log "Package: $($ppkgFile.Name)" + +# --- Set computer name to E --- +$serial = (Get-CimInstance Win32_BIOS).SerialNumber +$newName = "E$serial" +Log "Setting computer name to $newName" +Rename-Computer -NewName $newName -Force -ErrorAction SilentlyContinue + +# --- Install provisioning package --- +Log "Installing provisioning package..." +try { + Install-ProvisioningPackage -PackagePath $ppkgFile.FullName -ForceInstall -QuietInstall + Log "Provisioning package installed successfully." +} catch { + Log "ERROR: Install-ProvisioningPackage failed: $_" + Log "Attempting fallback with Add-ProvisioningPackage..." + try { + Add-ProvisioningPackage -PackagePath $ppkgFile.FullName -ForceInstall -QuietInstall + Log "Provisioning package added successfully (fallback)." + } catch { + Log "ERROR: Fallback also failed: $_" + exit 1 + } +} + +# --- Set OOBE complete --- +Log "Setting OOBE as complete..." +reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE" /v OOBEComplete /t REG_DWORD /d 1 /f | Out-Null +reg add "HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Setup\OOBE" /v SetupDisplayedEula /t REG_DWORD /d 1 /f | Out-Null + +# --- Stage the imaging chain for next boot --- +# The PPKG schedules a reboot (PendingFileRenameOperations for Zscaler +# rename, PPKG self-cleanup, etc). Instead of canceling it and cramming +# Run-ShopfloorSetup into this same session, we let the reboot happen +# and register a RunOnce entry that fires Stage-Dispatcher.ps1 on the +# next autologon. The dispatcher reads setup-stage.txt and chains +# through: shopfloor-setup -> sync-intune -> configure-pc. +$stageFile = 'C:\Enrollment\setup-stage.txt' +$dispatcherPath = 'C:\Enrollment\Stage-Dispatcher.ps1' +$runOnceKey = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\RunOnce' + +Log "Writing stage file: shopfloor-setup" +Set-Content -LiteralPath $stageFile -Value 'shopfloor-setup' -Force + +if (Test-Path -LiteralPath $dispatcherPath) { + Log "Registering RunOnce for Stage-Dispatcher.ps1" + Set-ItemProperty -Path $runOnceKey -Name 'ShopfloorSetup' ` + -Value "powershell.exe -NoProfile -ExecutionPolicy Bypass -File `"$dispatcherPath`"" ` + -Type String -Force +} else { + Log "WARNING: Stage-Dispatcher.ps1 not found at $dispatcherPath - RunOnce not set" +} + +Log "=== Enrollment complete. PPKG reboot will fire and Stage-Dispatcher picks up on next logon. ===" diff --git a/playbook/startnet.cmd b/playbook/startnet.cmd index 6a32409..12c5a5a 100644 --- a/playbook/startnet.cmd +++ b/playbook/startnet.cmd @@ -224,6 +224,7 @@ if errorlevel 1 ( goto copy_pctype ) copy /Y "Y:\run-enrollment.ps1" "W:\run-enrollment.ps1" +copy /Y "Y:\shopfloor-setup\Stage-Dispatcher.ps1" "W:\Enrollment\Stage-Dispatcher.ps1" REM --- Create enroll.cmd at drive root as manual fallback --- > W:\enroll.cmd (