When the runner runs a Type:MSI install it injects /L*v <log> and tails
that log on failure to show what actually went wrong. Type:EXE installs
had no equivalent - if Setup-OpenText.cmd or any other EXE wrapper
failed, the installlog just showed "Exit code 1 - FAILED" with no clue
what happened inside.
Adds an optional LogFile field to JSON entries. When present on a
Type:EXE entry, the runner:
- Logs "Installer log: <path>" before launching the installer
- On failure, tails the last 30 lines of that file into the runner
log (same pattern as the MSI verbose log scan)
Wired up on the OpenText entry to point at C:\Logs\PreInstall\Setup-
OpenText.log (which Setup-OpenText.ps1 already writes itself). Other
EXE entries can opt in by adding their own LogFile field.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
369 lines
15 KiB
PowerShell
369 lines
15 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"
|
|
|
|
# --- 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 from C:\Enrollment\pc-type.txt (set by startnet.cmd) ---
|
|
$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
|
|
}
|
|
Write-PreInstallLog "PC type: $pcType"
|
|
|
|
# --- 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"
|
|
|
|
# --- 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.
|
|
& shutdown.exe /a 2>$null
|
|
|
|
Write-PreInstallLog "==> $($app.Name)"
|
|
|
|
# Filter by PCTypes
|
|
$allowedTypes = @($app.PCTypes)
|
|
$matchesType = ($allowedTypes -contains "*") -or ($allowedTypes -contains $pcType)
|
|
if (-not $matchesType) {
|
|
Write-PreInstallLog " PCTypes filter excludes '$pcType' (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 }
|
|
|
|
# If detection passes mid-install, the installer already did its job -
|
|
# we can kill any zombie process (like UDC_Setup.exe waiting on its hidden
|
|
# WPF window) and move on.
|
|
if (Test-AppInstalled -App $app) {
|
|
Write-PreInstallLog " Detection passed at $elapsed s - killing installer to advance"
|
|
try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch { }
|
|
|
|
# UDC's installer auto-launches UDC.exe in silent mode. Kill that too
|
|
# so it can't write the placeholder MachineNumber to udc_settings.json.
|
|
if ($app.Name -eq "UDC") {
|
|
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.
|
|
& shutdown.exe /a 2>$null
|
|
|
|
exit 0
|