Adds a local-install pipeline so Standard shopfloor PCs get Oracle, the
VC++ redists (2008-2022), and UDC installed during PXE imaging via Samba
instead of pulling ~215 MB per device from Azure blob over the corporate
WAN. Intune DSC then verifies (already-installed apps are skipped) and
the only Azure traffic on the happy path is ~11 KB of CustomScripts
wrapper polling.
New files:
- playbook/preinstall/preinstall.json — curated app list with PCTypes
filter and per-app detection rules. Install order puts VC++ 2008
LAST so its (formerly) reboot-triggering bootstrapper doesn't kill
the runner mid-loop. (2008 itself now uses extracted vc_red.msi with
REBOOT=ReallySuppress; the reorder is defense in depth.)
- playbook/shopfloor-setup/Shopfloor/00-PreInstall-MachineApps.ps1 —
the runner. Numbered 00- so it runs first in the baseline sequence.
Reads preinstall.json, filters by PCTYPE, polls for completion via
detection check (handles UDC's hung WPF process by killing it once
detection passes), uses synchronous WriteThrough logging that
survives hard reboots, preserves log history across runs.
- playbook/shopfloor-setup/Standard/Set-MachineNumber.{ps1,bat} — desktop
helper for SupportUser. Reads current UDC + eDNC machine numbers,
prompts via VB InputBox, validates digits-only, kills running UDC,
edits both C:\ProgramData\UDC\udc_settings.json and HKLM\…\GE Aircraft
Engines\DNC\General\MachineNo, relaunches UDC. Lets a tech assign a
real machine number to a mass-produced PC without admin/LAPS.
- playbook/sync-preinstall.sh — workstation helper to push installer
binaries from /home/camp/pxe-images/main/ to the live PXE Samba.
Changes:
- playbook/startnet.cmd + startnet-template.cmd — add xcopy to stage
preinstall bundle from Y:\preinstall\ to W:\PreInstall\ during the
WinPE imaging phase, gated on PCTYPE being set.
- playbook/pxe_server_setup.yml — create /srv/samba/enrollment/preinstall
+ installers/ directories and deploy preinstall.json there.
- playbook/shopfloor-setup/Run-ShopfloorSetup.ps1 — bump AutoLogonCount
to 99 at start (defense against any installer triggering an immediate
reboot mid-dispatcher; final line still resets to 2 on successful
completion). Copy Set-MachineNumber.{ps1,bat} to SupportUser desktop
on Standard PCs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
285 lines
10 KiB
PowerShell
285 lines
10 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,
|
|
[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)"
|
|
}
|
|
|
|
try {
|
|
# Start without -Wait so we can poll. UDC_Setup.exe in particular hangs forever
|
|
# after install (it spawns UDC.exe and hides its main window without exiting),
|
|
# so we can't rely on the process exiting on its own.
|
|
if ($app.Type -eq "MSI") {
|
|
$msiArgs = "/i `"$installerPath`""
|
|
if ($app.InstallArgs) {
|
|
$msiArgs += " " + $app.InstallArgs
|
|
}
|
|
$proc = Start-Process -FilePath "msiexec.exe" -ArgumentList $msiArgs -PassThru -NoNewWindow
|
|
}
|
|
elseif ($app.Type -eq "EXE") {
|
|
if ($app.InstallArgs) {
|
|
$proc = Start-Process -FilePath $installerPath -ArgumentList $app.InstallArgs -PassThru -NoNewWindow
|
|
} else {
|
|
$proc = Start-Process -FilePath $installerPath -PassThru -NoNewWindow
|
|
}
|
|
}
|
|
else {
|
|
Write-PreInstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR"
|
|
$failed++
|
|
continue
|
|
}
|
|
|
|
# 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++
|
|
}
|
|
elseif ($proc.ExitCode -eq 0 -or $proc.ExitCode -eq 3010) {
|
|
Write-PreInstallLog " Exit code $($proc.ExitCode) after $elapsed s - SUCCESS"
|
|
if ($proc.ExitCode -eq 3010) {
|
|
Write-PreInstallLog " (Reboot required for $($app.Name))"
|
|
}
|
|
$installed++
|
|
}
|
|
else {
|
|
Write-PreInstallLog " Exit code $($proc.ExitCode) - FAILED" "ERROR"
|
|
$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
|