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>
458 lines
20 KiB
PowerShell
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
|