Files
pxe-server/playbook/shopfloor-setup/Shopfloor/00-PreInstall-MachineApps.ps1
cproudlock 2db35c2976 UDC: correct CLI arg signature to compact site + dash-prefixed machine#
UDC_Setup.exe and UDC.exe expect:
  UDC_Setup.exe WestJefferson -7605

Not the spaced-quoted positional pair we'd been passing:
  UDC_Setup.exe "West Jefferson" 7605

The wrong format meant UDC ignored both args, fell back to defaults
(Site=Evendale, MachineNumber=blank). Combined with the kill-after-detect
window, neither value got persisted to udc_settings.json regardless of
whether UDC.exe was given time to write.

Changes:
- preinstall.json: UDC InstallArgs now "WestJefferson -9999"
- 00-PreInstall-MachineApps.ps1: site override now matches/replaces
  the compact 'WestJefferson' token (not 'West Jefferson') and uses
  siteNameCompact from site-config; targetNum extraction regex updated
  to '-(\d+)$' for the new dash-prefix format
- Update-MachineNumber.ps1: UDC.exe relaunch now passes positional
  compact-site + dash-prefixed number instead of -site/-machine flags

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 17:47:57 -04:00

458 lines
20 KiB
PowerShell

# 00-PreInstall-MachineApps.ps1 - Install pre-staged apps from C:\PreInstall (Shopfloor baseline)
#
# Reads C:\PreInstall\preinstall.json (staged by startnet.cmd during WinPE phase) and
# installs each app whose PCTypes filter matches the current PC type. Detection runs first
# so already-installed apps are skipped. Numbered 00- so it runs before all other Shopfloor
# baseline scripts - apps need to exist before later scripts (e.g. 01-eDNC.ps1) reference
# them or rely on their state.
#
# Failure mode: log + continue. Intune DSC (Simple-Install.ps1) is the safety net for any
# install that fails here.
#
# Logs to C:\Logs\PreInstall\install.log (wiped each run).
$preInstallDir = "C:\PreInstall"
$jsonPath = Join-Path $preInstallDir "preinstall.json"
$installerDir = Join-Path $preInstallDir "installers"
$logDir = "C:\Logs\PreInstall"
$logFile = Join-Path $logDir "install.log"
# --- Inline site-config reader (same pattern as Setup-OpenText.ps1; this script
# runs from C:\Enrollment\shopfloor-setup\Shopfloor\ but Get-PCProfile.ps1 may
# not be available yet, so keep the lookup self-contained) ---
function Get-SiteConfig {
$configPath = 'C:\Enrollment\site-config.json'
if (-not (Test-Path $configPath)) { return $null }
try {
return Get-Content $configPath -Raw | ConvertFrom-Json
} catch {
return $null
}
}
# --- Setup logging ---
# IMPORTANT: do NOT wipe the log on each run. The runner can get killed by an
# installer-triggered reboot mid-execution. On the next autologon, the dispatcher
# re-runs us - if we wipe the log here, we destroy the forensic record from the
# previous (interrupted) run. Instead, append a session header so runs are visible
# but history is preserved.
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
function Write-PreInstallLog {
param(
[Parameter(Mandatory=$true, Position=0)]
[string]$Message,
[Parameter(Position=1)]
[ValidateSet('INFO','WARN','ERROR')]
[string]$Level = 'INFO'
)
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss"
$line = "[$stamp] [$Level] $Message"
Write-Host $line
# Synchronous write-through so each line hits disk immediately, even if a system
# reboot kills the process right after this call returns. Bypasses the OS write
# cache via FileOptions.WriteThrough.
try {
$fs = New-Object System.IO.FileStream(
$logFile,
[System.IO.FileMode]::Append,
[System.IO.FileAccess]::Write,
[System.IO.FileShare]::Read,
4096,
[System.IO.FileOptions]::WriteThrough
)
$bytes = [System.Text.Encoding]::UTF8.GetBytes($line + "`r`n")
$fs.Write($bytes, 0, $bytes.Length)
$fs.Flush()
$fs.Dispose()
}
catch {
# Last-resort fallback if the FileStream open fails for any reason
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
}
}
# Session header so multiple runs are visually distinguishable in the log
Write-PreInstallLog "================================================================"
Write-PreInstallLog "=== Runner session start (PID $PID) ==="
Write-PreInstallLog "================================================================"
# --- Bail early if no preinstall bundle staged ---
if (-not (Test-Path $jsonPath)) {
Write-PreInstallLog "No preinstall.json at $jsonPath - skipping (no bundle staged for this PC)."
exit 0
}
# --- Read PCTYPE and optional PCSUBTYPE from C:\Enrollment\ ---
$pcTypeFile = "C:\Enrollment\pc-type.txt"
if (-not (Test-Path $pcTypeFile)) {
Write-PreInstallLog "No pc-type.txt at $pcTypeFile - skipping" "WARN"
exit 0
}
$pcType = (Get-Content $pcTypeFile -First 1).Trim()
if (-not $pcType) {
Write-PreInstallLog "pc-type.txt is empty - skipping" "WARN"
exit 0
}
$pcSubtype = ''
$subtypeFile = "C:\Enrollment\pc-subtype.txt"
if (Test-Path $subtypeFile) {
$pcSubtype = (Get-Content $subtypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
}
$pcProfileKey = if ($pcSubtype) { "$pcType-$pcSubtype" } else { $pcType }
Write-PreInstallLog "PC type: $pcType$(if ($pcSubtype) { " (subtype: $pcSubtype, profile: $pcProfileKey)" })"
# --- Parse JSON ---
try {
$config = Get-Content $jsonPath -Raw | ConvertFrom-Json
} catch {
Write-PreInstallLog "Failed to parse $jsonPath : $_" "ERROR"
exit 0
}
if (-not $config.Applications) {
Write-PreInstallLog "No Applications in preinstall.json"
exit 0
}
Write-PreInstallLog "Staged installer dir: $installerDir"
Write-PreInstallLog "Found $($config.Applications.Count) app entries in preinstall.json"
# --- Site-name override: replace 'WestJefferson' (compact, no space) in
# InstallArgs if site-config says otherwise. UDC_Setup.exe expects the
# compact site name as positional arg 1; the spaced variant is used in
# other places (eDNC SITESELECTED MSI property) but not here.
$siteConfig = Get-SiteConfig
if ($siteConfig -and $siteConfig.siteNameCompact -and $siteConfig.siteNameCompact -ne 'WestJefferson') {
Write-PreInstallLog "Site config loaded - siteNameCompact: $($siteConfig.siteNameCompact)"
foreach ($app in $config.Applications) {
if ($app.InstallArgs -and $app.InstallArgs -match 'WestJefferson') {
$app.InstallArgs = $app.InstallArgs -replace 'WestJefferson', $siteConfig.siteNameCompact
Write-PreInstallLog " Overrode site name in $($app.Name) args: $($app.InstallArgs)"
}
}
} else {
Write-PreInstallLog "No site-config override for siteNameCompact (using defaults in preinstall.json)"
}
# --- Machine-number override: replace "9999" in UDC InstallArgs if tech entered a number ---
$machineNumFile = 'C:\Enrollment\machine-number.txt'
if (Test-Path -LiteralPath $machineNumFile) {
$machineNum = (Get-Content -LiteralPath $machineNumFile -First 1 -ErrorAction SilentlyContinue).Trim()
if ($machineNum -and $machineNum -ne '9999' -and $machineNum -match '^\d+$') {
Write-PreInstallLog "Machine number from PXE menu: $machineNum"
foreach ($app in $config.Applications) {
if ($app.InstallArgs -and $app.InstallArgs -match '9999') {
$app.InstallArgs = $app.InstallArgs -replace '9999', $machineNum
Write-PreInstallLog " Overrode machine number in $($app.Name) args: $($app.InstallArgs)"
}
}
} else {
Write-PreInstallLog "Machine number: $machineNum (default placeholder)"
}
} else {
Write-PreInstallLog "No machine-number.txt found (using 9999 default)"
}
# --- Detection helper (mirrors Simple-Install.ps1's Test-ApplicationInstalled) ---
function Test-AppInstalled {
param($App)
if (-not $App.DetectionMethod) {
return $false
}
try {
switch ($App.DetectionMethod) {
"Registry" {
if (-not (Test-Path $App.DetectionPath)) {
return $false
}
if ($App.DetectionName) {
$value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
if (-not $value) {
return $false
}
if ($App.DetectionValue) {
return ($value.$($App.DetectionName) -eq $App.DetectionValue)
}
return $true
}
return $true
}
"File" {
return Test-Path $App.DetectionPath
}
default {
Write-PreInstallLog " Unknown detection method: $($App.DetectionMethod)" "WARN"
return $false
}
}
} catch {
Write-PreInstallLog " Detection check threw: $_" "WARN"
return $false
}
}
# --- Iterate apps ---
$installed = 0
$skipped = 0
$failed = 0
foreach ($app in $config.Applications) {
# Cancel any reboot a previous installer scheduled (some respect /norestart, some
# don't - VC++ 2008's bootstrapper sometimes triggers an immediate Windows reboot
# despite the flag). Doing this BEFORE each install protects the rest of the loop.
cmd /c "shutdown /a 2>nul" *>$null
Write-PreInstallLog "==> $($app.Name)"
# Filter by PCTypes - matches on wildcard, base type, or composite type-subtype key
$allowedTypes = @($app.PCTypes)
$matchesType = ($allowedTypes -contains "*") -or ($allowedTypes -contains $pcType) -or ($pcProfileKey -ne $pcType -and $allowedTypes -contains $pcProfileKey)
if (-not $matchesType) {
Write-PreInstallLog " PCTypes filter excludes '$pcProfileKey' (allowed: $($allowedTypes -join ', ')) - skipping"
$skipped++
continue
}
# Detection check
if (Test-AppInstalled -App $app) {
Write-PreInstallLog " Already installed - skipping"
$skipped++
continue
}
# Locate installer file
$installerPath = Join-Path $installerDir $app.Installer
if (-not (Test-Path $installerPath)) {
Write-PreInstallLog " Installer file not found: $installerPath" "ERROR"
$failed++
continue
}
Write-PreInstallLog " Installing from $installerPath"
if ($app.InstallArgs) {
Write-PreInstallLog " InstallArgs: $($app.InstallArgs)"
}
# Per-app verbose msiexec log path - written by /L*v on MSI installs and tailed
# into the runner log on failure so we don't need to dig through C:\Logs manually.
$safeName = $app.Name -replace '[^a-zA-Z0-9]','_'
$msiLog = Join-Path $logDir "msi-$safeName.log"
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
try {
# We use [System.Diagnostics.Process]::Start() directly instead of Start-Process
# because PowerShell 5.1's Start-Process -PassThru has a bug where the returned
# Process object's OS handle is disposed when control returns to the script,
# causing ExitCode to always read as $null even after WaitForExit() succeeds.
# Calling Process.Start() directly returns a Process object with a live handle
# that exposes ExitCode correctly. We poll HasExited so UDC_Setup.exe (which
# hangs forever after spawning a hidden WPF window) can be killed via the
# detection-during-install path.
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
if ($app.Type -eq "MSI") {
$psi.FileName = "msiexec.exe"
$psi.Arguments = "/i `"$installerPath`""
if ($app.InstallArgs) {
$psi.Arguments += " " + $app.InstallArgs
}
$psi.Arguments += " /L*v `"$msiLog`""
Write-PreInstallLog " msiexec verbose log: $msiLog"
}
elseif ($app.Type -eq "EXE") {
$psi.FileName = $installerPath
if ($app.InstallArgs) {
$psi.Arguments = $app.InstallArgs
}
# If the JSON entry specifies a LogFile (per-installer log written by
# the EXE itself - e.g. Setup-OpenText.ps1 -> C:\Logs\PreInstall\Setup-
# OpenText.log), surface it on failure the same way we surface the
# msiexec /L*v verbose log for MSI installs. Lets EXE wrappers actually
# report what went wrong inside.
if ($app.LogFile) {
Write-PreInstallLog " Installer log: $($app.LogFile)"
}
}
else {
Write-PreInstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR"
$failed++
continue
}
$proc = [System.Diagnostics.Process]::Start($psi)
# Poll for completion: process exit OR detection success (whichever happens first)
$timeoutSec = 600 # 10 min hard cap per app
$pollInterval = 5
$elapsed = 0
$killedAfterDetect = $false
while ($elapsed -lt $timeoutSec) {
if ($proc.HasExited) { break }
# Detection-during-install kill is OPT-IN via "KillAfterDetection: true"
# in the JSON entry. It's only safe for installers that hang forever after
# creating their detection markers (UDC_Setup.exe spawns a hidden WPF window
# and never exits). For normal installers (Oracle, msiexec, etc.) the
# detection registry key often gets written midway through the install AND
# msiexec is still doing post-install cleanup AND msiserver is still holding
# the install mutex - killing msiexec at that point leaves the system in a
# bad state and the NEXT msiexec call returns 1618 ERROR_INSTALL_ALREADY_
# RUNNING. So opt-out by default; only kill apps that explicitly need it.
if ($app.KillAfterDetection -and (Test-AppInstalled -App $app)) {
Write-PreInstallLog " Detection passed at $elapsed s - killing installer to advance (KillAfterDetection)"
try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch { }
# UDC's installer auto-launches UDC.exe silently; it's UDC.exe
# (not UDC_Setup) that actually writes udc_settings.json from
# the CLI args. Killing it immediately after the detection
# marker appears loses the MachineNumber write. Wait (up to
# 15s) for settings.json to reflect the target number, then
# kill. Works uniformly for tech-typed numbers and the 9999
# placeholder - whichever was passed as arg 2 is what we
# expect to see persist.
if ($app.Name -eq "UDC") {
$udcJson = 'C:\ProgramData\UDC\udc_settings.json'
$targetNum = $null
if ($app.InstallArgs -match '-(\d+)\s*$') { $targetNum = $matches[1] }
$waitSec = 0
while ($waitSec -lt 15) {
if (Test-Path $udcJson) {
try {
$mn = (Get-Content $udcJson -Raw -ErrorAction Stop |
ConvertFrom-Json).GeneralSettings.MachineNumber
if ($mn -and $mn -eq $targetNum) {
Write-PreInstallLog " UDC persisted MachineNumber=$mn after $waitSec s"
break
}
} catch { }
}
Start-Sleep -Seconds 1
$waitSec++
}
if ($waitSec -ge 15) {
Write-PreInstallLog " UDC did not persist MachineNumber within 15 s - killing anyway" "WARN"
}
Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
try { $_.Kill(); $_.WaitForExit(2000) | Out-Null } catch { }
}
}
$killedAfterDetect = $true
break
}
Start-Sleep -Seconds $pollInterval
$elapsed += $pollInterval
}
if (-not $proc.HasExited -and -not $killedAfterDetect) {
Write-PreInstallLog " TIMEOUT after $timeoutSec seconds - killing installer" "ERROR"
try { $proc.Kill() } catch { }
if ($app.Name -eq "UDC") {
Get-Process UDC -ErrorAction SilentlyContinue | ForEach-Object {
try { $_.Kill() } catch { }
}
}
$failed++
continue
}
if ($killedAfterDetect) {
Write-PreInstallLog " SUCCESS (verified via detection during install)"
$installed++
}
else {
# Start-Process -PassThru returns a Process object whose ExitCode is null
# until WaitForExit() is called - even if HasExited is already true. Without
# this, every install that goes through the exit-code branch gets reported
# as "Exit code - FAILED" (empty) regardless of the real result.
try { $proc.WaitForExit() } catch { }
$exitCode = $proc.ExitCode
if ($exitCode -eq 0 -or $exitCode -eq 3010) {
Write-PreInstallLog " Exit code $exitCode after $elapsed s - SUCCESS"
if ($exitCode -eq 3010) {
Write-PreInstallLog " (Reboot required for $($app.Name))"
}
$installed++
}
else {
Write-PreInstallLog " Exit code $exitCode - FAILED" "ERROR"
# If the JSON entry specifies a LogFile for an EXE install (e.g.
# Setup-OpenText.ps1 writes its own log at C:\Logs\PreInstall\Setup-
# OpenText.log), tail it here so the runner log surfaces the actual
# cause without us having to dig through C:\Logs manually.
if ($app.Type -eq "EXE" -and $app.LogFile -and (Test-Path $app.LogFile)) {
Write-PreInstallLog " --- last 30 lines of $($app.LogFile) ---"
Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object {
Write-PreInstallLog " $_"
}
Write-PreInstallLog " --- end installer log tail ---"
}
# Surface the *meaningful* lines from the verbose msiexec log (not just
# the tail, which is rollback/cleanup noise). The actual root-cause lines
# are: MSI error notes (Note: 1: <code>), failed action returns (return
# value 3), errors logged by custom actions, and any line containing the
# word "Error". Grep these out so the runner log shows the smoking gun.
if ($app.Type -eq "MSI" -and (Test-Path $msiLog)) {
Write-PreInstallLog " --- meaningful lines from $msiLog ---"
$patterns = @(
'Note: 1: ',
'return value 3',
'Error \d+\.',
'CustomAction .* returned actual error',
'Failed to ',
'Installation failed',
'Calling custom action',
'1: 2262', # Database not found
'1: 2203', # Cannot open database
'1: 2330' # Insufficient permissions
)
$regex = ($patterns -join '|')
$matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue |
Select-Object -First 30
if ($matches) {
foreach ($m in $matches) {
Write-PreInstallLog " $($m.Line.Trim())"
}
} else {
Write-PreInstallLog " (no error patterns matched - falling back to last 25 lines)"
Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object {
Write-PreInstallLog " $_"
}
}
Write-PreInstallLog " --- end MSI log scan ---"
}
$failed++
}
}
} catch {
Write-PreInstallLog " Install threw: $_" "ERROR"
$failed++
}
}
Write-PreInstallLog "============================================"
Write-PreInstallLog "PreInstall complete: $installed installed, $skipped skipped, $failed failed"
Write-PreInstallLog "============================================"
# Final reboot cancel - if the last installer in the loop scheduled one, the
# dispatcher's later `shutdown /a` won't fire until the next baseline script starts.
# Cancel here so control returns cleanly to Run-ShopfloorSetup.ps1.
cmd /c "shutdown /a 2>nul" *>$null
exit 0