Files
pxe-server/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1
cproudlock 7a67716fcc manifest engine: null-safe PS1 dispatch (accept Script or Installer, log resolved path)
PS1 entries were crashing with a cryptic "Cannot bind LiteralPath because it is
null" when the resolved script path came back null - the per-entry try/catch
caught it (so the scope survived) but the cause was opaque. Now the PS1 branch
accepts either Script or Installer, null-guards before Join-Path/Test-Path, and
logs the resolved relative path, so a bad/empty entry is skipped with a clear
"has no Script/Installer value" line instead of a null-bind throw.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-03 17:38:16 -04:00

719 lines
34 KiB
PowerShell

# Install-FromManifest.ps1 - Generic JSON-manifest runner for shopfloor apps.
#
# Stage 2a extension: adds PS1, BAT, File, Registry, INF action types and
# Always / MarkerFile / ValueMatches / pnputil detection methods. Also adds
# optional PCTypes filtering (entry skipped if current pc-type.txt doesn't
# match any value in the entry's PCTypes array).
#
# Manifest-schema fields parsed-but-not-yet-acted-on (Stage 2b will wire them):
# ApplyMode, UpdateWindow, InUseCheck. The lib treats all entries as
# immediate-apply today; Stage 2b adds shift-window gating and
# close-and-reopen behavior.
#
# Consumers:
# - GE-Enforce.ps1 (new unified dispatcher, runs via scheduled task with
# both logon and periodic triggers)
# - Legacy *-Enforce.ps1 that still point here (will be retired)
#
# Exit codes:
# 0 = every required entry either already satisfied or installed successfully
# 1 = at least one entry failed
# 2 = manifest or installer-root could not be read
param(
[Parameter(Mandatory=$true)] [string]$ManifestPath,
[Parameter(Mandatory=$true)] [string]$InstallerRoot,
[Parameter(Mandatory=$true)] [string]$LogFile,
[Parameter(Mandatory=$false)] [string]$PCType,
[Parameter(Mandatory=$false)] [string]$PCSubType
)
$ErrorActionPreference = 'Continue'
# Lib's supported manifest schema version. Bump the MAJOR part whenever a
# manifest field or behavior changes in a way older libs can't handle.
# Bump the MINOR part for additive, backward-compatible additions (new
# optional field, new Type, new DetectionMethod). Manifests tagged with
# a newer MAJOR than the lib get processed best-effort with unknown Types
# logged; manifests tagged with a newer MINOR are fine.
#
# Changelog:
# 2.5 - Type=EXE handler honors optional WaitTimeoutSec on the manifest
# entry. WiX Burn bootstrappers (UDC_Setup.exe) install the MSI
# successfully but the wrapper process never exits (waits on a
# bundled child service). With WaitTimeoutSec set, kill the
# wrapper after timeout, re-check Test-Installed - if pass,
# treat as success (rc 0); if fail, surface as -2.
# 2.4 - Type=EXE handler stages network-share EXEs to a local temp dir
# before invoking Process.Start. SYSTEM-context Process.Start
# fails with "Access is denied" on \\share or mapped-drive EXE
# paths (empirically confirmed with UDC_Setup.exe 2026-05-02).
# Local invocation works. Cleanup is best-effort in finally.
# 2.3 - PCTypes filter accepts old (Standard, Standard-Machine, CMM, ...)
# and new (gea-shopfloor-collections, gea-shopfloor-cmm, ...) names
# interchangeably via alias sets. Transitional for the rename reorg.
# 2.2 - added TargetMachineNumbers filter (reads C:\Enrollment\machine-number.txt
# then falls back to DNC registry HKLM\...\GE Aircraft Engines\DNC\General\MachineNo)
# 2.1 - added TargetHostnames filter (exact + -like wildcards)
# 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types,
# Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter
$LIB_MANIFEST_MAJOR = 2
$LIB_MANIFEST_MINOR = 5
$logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
function Write-InstallLog {
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
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 {
Add-Content -Path $LogFile -Value $line -ErrorAction SilentlyContinue
}
}
Write-InstallLog '================================================================'
Write-InstallLog "=== Install-FromManifest session start (PID $PID) ==="
Write-InstallLog "Manifest: $ManifestPath"
Write-InstallLog "InstallerRoot: $InstallerRoot"
if ($PCType) { Write-InstallLog "PCType: $PCType" }
if ($PCSubType) { Write-InstallLog "PCSubType: $PCSubType" }
Write-InstallLog '================================================================'
if (-not (Test-Path -LiteralPath $ManifestPath)) {
Write-InstallLog "Manifest not found: $ManifestPath" 'ERROR'
exit 2
}
if (-not (Test-Path -LiteralPath $InstallerRoot)) {
Write-InstallLog "InstallerRoot not found: $InstallerRoot" 'ERROR'
exit 2
}
try {
$config = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
} catch {
Write-InstallLog "Failed to parse manifest: $_" 'ERROR'
exit 2
}
if (-not $config.Applications -or $config.Applications.Count -eq 0) {
Write-InstallLog 'No Applications in manifest - nothing to do'
exit 0
}
# ---------------------------------------------------------------------------
# Manifest schema version check. Accepts "X" or "X.Y" form. If the manifest
# declares a MAJOR higher than the lib supports, log a clear warning so the
# operator sees "upgrade the lib on this PC" instead of weird silent
# skip-everything behavior. The lib still tries to run (unknown Types will
# log as ERROR and count as failed, but supported entries work).
# ---------------------------------------------------------------------------
$manifestMajor = 0
$manifestMinor = 0
if ($config.Version) {
$parts = "$($config.Version)".Split('.')
if ($parts.Length -ge 1 -and [int]::TryParse($parts[0], [ref]$null)) { $manifestMajor = [int]$parts[0] }
if ($parts.Length -ge 2 -and [int]::TryParse($parts[1], [ref]$null)) { $manifestMinor = [int]$parts[1] }
}
if ($manifestMajor -gt $LIB_MANIFEST_MAJOR) {
Write-InstallLog " Manifest schema version $($config.Version) is newer than this lib (supports $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR). Unknown Types/fields will be logged as errors. Upgrade the lib on this PC." 'WARN'
} elseif ($manifestMajor -eq $LIB_MANIFEST_MAJOR -and $manifestMinor -gt $LIB_MANIFEST_MINOR) {
Write-InstallLog " Manifest schema $($config.Version) is minor-newer than lib $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR. Should be backward compatible; any unknown Types will be logged." 'INFO'
}
Write-InstallLog "Manifest lists $($config.Applications.Count) app(s)"
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
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
}
'FileVersion' {
if (-not (Test-Path $App.DetectionPath)) { return $false }
if (-not $App.DetectionValue) {
Write-InstallLog ' FileVersion detection requires DetectionValue - treating as not installed' 'WARN'
return $false
}
$actual = (Get-Item $App.DetectionPath -ErrorAction Stop).VersionInfo.FileVersion
if (-not $actual) { return $false }
return ($actual -eq $App.DetectionValue)
}
'Hash' {
if (-not (Test-Path $App.DetectionPath)) { return $false }
if (-not $App.DetectionValue) {
Write-InstallLog ' Hash detection requires DetectionValue - treating as not installed' 'WARN'
return $false
}
$actual = (Get-FileHash -Path $App.DetectionPath -Algorithm SHA256 -ErrorAction Stop).Hash
return ($actual -ieq $App.DetectionValue)
}
'MarkerFile' {
# Used for one-shot PS1 scripts. Presence of the marker file
# means the script already ran successfully. The lib writes
# the marker automatically after a 0 exit on PS1/BAT/CMD.
if (-not $App.DetectionPath) { return $false }
return Test-Path $App.DetectionPath
}
'ValueMatches' {
# For Type=Registry entries. Check that the registry value
# equals the desired RegValue (read from the app entry).
if (-not (Test-Path $App.DetectionPath)) { return $false }
if (-not $App.DetectionName) {
Write-InstallLog ' ValueMatches detection requires DetectionName' 'WARN'
return $false
}
$p = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
if (-not $p) { return $false }
return ("$($p.$($App.DetectionName))" -eq "$($App.RegValue)")
}
'pnputil' {
# For INF drivers. Run pnputil /enum-drivers once and grep.
if (-not $App.DetectionPattern) {
Write-InstallLog ' pnputil detection requires DetectionPattern' 'WARN'
return $false
}
$drivers = & pnputil /enum-drivers 2>&1 | Out-String
return ($drivers -match $App.DetectionPattern)
}
'Always' {
# Never considered installed - entry runs every cycle.
# Useful for idempotent scripts like the VNC firewall rule.
return $false
}
default {
Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" 'WARN'
return $false
}
}
} catch {
Write-InstallLog " Detection check threw: $_" 'WARN'
return $false
}
}
# ---------------------------------------------------------------------------
# Action dispatch
# ---------------------------------------------------------------------------
function Invoke-InstallerAction {
param($App)
$psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.UseShellExecute = $false
$psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
switch ($App.Type) {
'MSI' {
$installerPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " MSI not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$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 }
$psi.FileName = 'msiexec.exe'
$psi.Arguments = "/i `"$installerPath`""
if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs }
$psi.Arguments += " /L*v `"$msiLog`""
Write-InstallLog " msiexec: $installerPath"
Write-InstallLog " verbose log: $msiLog"
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog }
}
'EXE' {
$installerPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " EXE not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
# Process.Start of an EXE that lives on a network share fails
# with "Access is denied" when the dispatcher runs as SYSTEM,
# even with a valid mount and no Mark-of-the-Web. Empirically
# confirmed 2026-05-02 with UDC_Setup.exe via qga: same path
# works once copied to a local C:\ dir. Stage to a per-cycle
# temp dir, run from there, clean up afterwards. Costs the
# transit time once per fire (most EXEs skip on subsequent
# cycles via detection so this rarely repeats).
$runPath = $installerPath
$isNetworkPath = ($installerPath -match '^\\\\') -or ($installerPath -match '^[A-Z]:\\' -and (Get-PSDrive -Name $installerPath.Substring(0,1) -ErrorAction SilentlyContinue).DisplayRoot)
$stagedPath = $null
if ($isNetworkPath) {
$stagedDir = Join-Path $env:TEMP ("ge-enforce-exe-" + [Guid]::NewGuid().ToString('N').Substring(0,8))
try {
New-Item -ItemType Directory -Path $stagedDir -Force | Out-Null
$leaf = Split-Path -Leaf $installerPath
$stagedPath = Join-Path $stagedDir $leaf
Copy-Item -LiteralPath $installerPath -Destination $stagedPath -Force -ErrorAction Stop
$runPath = $stagedPath
Write-InstallLog " staged network EXE -> $runPath"
} catch {
Write-InstallLog " failed to stage EXE locally: $_ - attempting direct invocation" 'WARN'
}
}
$psi.FileName = $runPath
if ($App.InstallArgs) { $psi.Arguments = $App.InstallArgs }
Write-InstallLog " exe: $runPath"
if ($App.InstallArgs) { Write-InstallLog " args: $($App.InstallArgs)" }
# Optional WaitTimeoutSec on the manifest entry: some EXE wrappers
# complete the install but don't exit themselves (UDC_Setup.exe is a
# WiX Burn bootstrapper that hangs post-install waiting on a child
# service that never returns control). When set, kill the wrapper
# after the timeout AND re-check detection - if installed, treat as
# success (rc 0); else surface as failure. Default unset = old
# behavior (block forever via WaitForExit).
$waitTimeoutMs = if ($App.WaitTimeoutSec) { [int]$App.WaitTimeoutSec * 1000 } else { -1 }
try {
$proc = [System.Diagnostics.Process]::Start($psi)
if ($waitTimeoutMs -gt 0) {
$exited = $proc.WaitForExit($waitTimeoutMs)
if (-not $exited) {
Write-InstallLog " WaitTimeoutSec=$($App.WaitTimeoutSec) reached - killing wrapper, will re-check detection" 'WARN'
try { $proc.Kill(); $proc.WaitForExit(5000) | Out-Null } catch {}
Start-Sleep -Seconds 2
if (Test-AppInstalled -App $App) {
Write-InstallLog " detection passes post-kill - treating as success"
$exitCode = 0
} else {
Write-InstallLog " detection still missing post-kill - failure" 'ERROR'
$exitCode = -2
}
} else {
$exitCode = $proc.ExitCode
}
} else {
$proc.WaitForExit()
$exitCode = $proc.ExitCode
}
} catch {
Write-InstallLog " Process.Start failed: $_" 'ERROR'
$exitCode = -1
} finally {
if ($stagedPath) {
try { Remove-Item -LiteralPath (Split-Path -Parent $stagedPath) -Recurse -Force -ErrorAction Stop } catch {}
}
}
return [pscustomobject]@{ ExitCode = $exitCode; LogRef = $App.LogFile }
}
{ $_ -eq 'CMD' -or $_ -eq 'BAT' } {
$installerPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$psi.FileName = 'cmd.exe'
$psi.Arguments = "/c `"$installerPath`""
if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs }
Write-InstallLog " cmd /c $installerPath"
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile }
}
'PS1' {
# Accept either Script or Installer as the relative path, and never
# feed a null into Join-Path/Test-Path (that throws a cryptic
# 'LiteralPath is null'). Log the resolved value so a bad/empty
# entry is obvious in the log instead of crashing the entry.
$rel = if ($App.Script) { $App.Script } elseif ($App.Installer) { $App.Installer } else { $null }
if ([string]::IsNullOrWhiteSpace([string]$rel)) {
Write-InstallLog (" PS1 entry '{0}' has no Script/Installer value (Script={1}, Installer={2}) - skipping" -f $App.Name, $App.Script, $App.Installer) 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$scriptPath = Join-Path $InstallerRoot $rel
if (-not (Test-Path -LiteralPath $scriptPath)) {
Write-InstallLog " PS1 not found: $scriptPath (from rel '$rel')" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$psi.FileName = 'powershell.exe'
$psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`""
if ($App.Args) { $psi.Arguments += " " + $App.Args }
Write-InstallLog " ps1: $scriptPath"
if ($App.Args) { Write-InstallLog " args: $($App.Args)" }
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null }
}
'INF' {
$infPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $infPath)) {
Write-InstallLog " INF not found: $infPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
Write-InstallLog " pnputil /add-driver `"$infPath`" /install"
$out = & pnputil /add-driver "$infPath" /install 2>&1 | Out-String
Write-InstallLog " pnputil output: $($out.Trim())"
# pnputil exits 0 on success, 259 if already installed, etc.
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; LogRef = $null }
}
'File' {
# Copy a file from the share (configs/*) to an absolute on-PC path.
$source = Join-Path $InstallerRoot $App.Source
if (-not (Test-Path -LiteralPath $source)) {
Write-InstallLog " File source not found: $source" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
if (-not $App.Destination) {
Write-InstallLog ' File entry missing Destination' 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$destDir = Split-Path -Parent $App.Destination
if ($destDir -and -not (Test-Path $destDir)) {
New-Item -Path $destDir -ItemType Directory -Force | Out-Null
}
try {
Copy-Item -LiteralPath $source -Destination $App.Destination -Force -ErrorAction Stop
Write-InstallLog " copied $source -> $($App.Destination)"
return [pscustomobject]@{ ExitCode = 0; LogRef = $null }
} catch {
Write-InstallLog " Copy failed: $_" 'ERROR'
return [pscustomobject]@{ ExitCode = 1; LogRef = $null }
}
}
'Registry' {
# Write a single registry value.
if (-not $App.RegPath -or -not $App.RegName) {
Write-InstallLog ' Registry entry missing RegPath/RegName' 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
if (-not (Test-Path $App.RegPath)) {
New-Item -Path $App.RegPath -Force | Out-Null
}
$type = if ($App.RegType) { $App.RegType } else { 'String' }
try {
Set-ItemProperty -Path $App.RegPath -Name $App.RegName -Value $App.RegValue -Type $type -Force -ErrorAction Stop
Write-InstallLog " set $($App.RegPath)\$($App.RegName) = $($App.RegValue) ($type)"
return [pscustomobject]@{ ExitCode = 0; LogRef = $null }
} catch {
Write-InstallLog " Registry write failed: $_" 'ERROR'
return [pscustomobject]@{ ExitCode = 1; LogRef = $null }
}
}
default {
Write-InstallLog " Unsupported Type: $($App.Type)" 'ERROR'
return [pscustomobject]@{ ExitCode = -2; LogRef = $null }
}
}
}
# ---------------------------------------------------------------------------
# Entry-applies filter. An entry applies to this PC only if:
# - PCTypes is omitted OR matches current pcType/pcType-subType/"*", AND
# - TargetHostnames is omitted OR matches current COMPUTERNAME
# (supports exact match and -like wildcards: "WJS-*", "*-SHOP-*").
# Both filters are ANDed so they compose: scope to a type AND a hostname
# subset, or either alone. Case-insensitive throughout.
# ---------------------------------------------------------------------------
# PCTypes alias map for the 2026-05-03 rename reorg. Manifests may use
# either old names (Standard, Standard-Machine, CMM, etc.) or new names
# (gea-shopfloor-collections, gea-shopfloor-cmm, etc.). Each entry below
# is a set of names that all match the same identity. The match logic
# resolves the current PC's identity AND each PCTypes entry into their
# alias sets, then matches if the sets intersect. See
# project-shopfloor-rename-reorg memory for the full rename plan.
$script:_pcTypeAliasGroups = @(
@('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections', 'gea-shopfloor-common'),
@('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'),
@('Standard-Timeclock', 'gea-shopfloor-common'),
@('CMM', 'gea-shopfloor-cmm'),
@('Keyence', 'gea-shopfloor-keyence'),
@('Lab', 'gea-shopfloor-common'),
@('WaxAndTrace', 'gea-shopfloor-waxtrace'),
@('Genspect', 'gea-shopfloor-genspect'),
@('Display', 'gea-shopfloor-display'),
@('Heattreat', 'gea-shopfloor-heattreat'),
@('PartMarker', 'gea-shopfloor-partmarker')
)
# Returns every alias set (each itself a string array) that contains $name.
# Multiple groups can return the same name (e.g. "Standard" appears in the
# super-group covering all three new shopfloor variants AND has its own
# subtype-specific groups - by design, matches widely).
function Get-PCTypeAliasSets {
param([string]$Name)
$hits = @()
foreach ($g in $script:_pcTypeAliasGroups) {
foreach ($n in $g) {
if ($n -ieq $Name) { $hits += ,$g; break }
}
}
return ,$hits
}
function Test-PCTypeMatches {
param($App, [string]$Type, [string]$SubType)
if (-not $App.PCTypes -or $App.PCTypes.Count -eq 0) { return $true }
if (-not $Type) { return $true }
# Build the set of strings the CURRENT PC matches: bare PCType,
# "Type-SubType", and every alias-set member of either.
$myNames = New-Object System.Collections.Generic.HashSet[string]([System.StringComparer]::OrdinalIgnoreCase)
[void]$myNames.Add($Type)
if ($SubType) { [void]$myNames.Add("$Type-$SubType") }
foreach ($n in @($Type, "$Type-$SubType") | Where-Object { $_ }) {
foreach ($g in (Get-PCTypeAliasSets -Name $n)) {
foreach ($alias in $g) { [void]$myNames.Add($alias) }
}
}
foreach ($t in $App.PCTypes) {
if ($t -eq '*') { return $true }
if ($myNames.Contains($t)) { return $true }
# Manifest entry's PCTypes value may itself be an alias - expand it
# and check overlap with the PC's identity set.
foreach ($g in (Get-PCTypeAliasSets -Name $t)) {
foreach ($alias in $g) {
if ($myNames.Contains($alias)) { return $true }
}
}
}
return $false
}
function Test-HostnameMatches {
param($App)
if (-not $App.TargetHostnames -or $App.TargetHostnames.Count -eq 0) { return $true }
# [System.Environment]::MachineName reads the live NetBIOS name from the
# kernel. $env:COMPUTERNAME is cached in the process environment at PS
# startup and is stale after a PC rename until the next reboot - which
# matters on Intune-managed PCs that get renamed post-imaging.
$myName = [System.Environment]::MachineName
foreach ($h in $App.TargetHostnames) {
if ($h -ieq $myName) { return $true }
if ($myName -ilike $h) { return $true } # glob patterns: WJS-*, *-SHOP-*
}
return $false
}
# Machine-number filter. Stable identifier tied to the bay; survives PC
# replacement at the same machine.
#
# Source of truth = the eDNC/DNC registry MachineNo. That is what the
# reassignment flow (Set-MachineNumber -> Update-MachineNumber) actually
# rewrites when a bay is re-numbered (e.g. 9999 placeholder -> 7501). The
# imaging-time C:\Enrollment\machine-number.txt is written ONCE by startnet.cmd
# at the PXE menu and is NOT updated on reassignment, so it goes stale. Read
# the registry FIRST so TargetMachineNumbers gating follows reassignment; fall
# back to the txt only when the registry has no value (covers non-DNC PCs or a
# bay where eDNC has not populated MachineNo yet).
$script:_cachedMachineNumber = $null
function Get-CurrentMachineNumber {
if ($null -ne $script:_cachedMachineNumber) { return $script:_cachedMachineNumber }
foreach ($r in @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\DNC\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\DNC\General'
)) {
if (Test-Path $r) {
$p = Get-ItemProperty -Path $r -ErrorAction SilentlyContinue
if ($p.MachineNo) {
$v = ([string]$p.MachineNo).Trim()
if ($v) { $script:_cachedMachineNumber = $v; return $script:_cachedMachineNumber }
}
}
}
foreach ($p in @('C:\Enrollment\machine-number.txt')) {
if (Test-Path -LiteralPath $p) {
$v = (Get-Content -LiteralPath $p -ErrorAction SilentlyContinue | Select-Object -First 1)
if ($v) { $script:_cachedMachineNumber = $v.Trim(); return $script:_cachedMachineNumber }
}
}
$script:_cachedMachineNumber = ''
return ''
}
function Test-MachineNumberMatches {
param($App)
if (-not $App.TargetMachineNumbers -or $App.TargetMachineNumbers.Count -eq 0) { return $true }
$myNumber = Get-CurrentMachineNumber
if (-not $myNumber) { return $false } # entry restricts by machine #, but PC has no machine # -> exclude
foreach ($n in $App.TargetMachineNumbers) {
if ([string]$n -ieq $myNumber) { return $true }
}
return $false
}
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
$installed = 0
$skipped = 0
$failed = 0
$pcFiltered = 0
foreach ($app in $config.Applications) {
# Cancel any reboot that a prior MSI queued, so the enforcer never
# triggers an unexpected restart on a shopfloor PC.
cmd /c 'shutdown /a 2>nul' *>$null
Write-InstallLog "==> $($app.Name)"
# Per-entry guard: a single entry that throws must NOT abort the whole
# scope (and silently skip every later entry + the status write). Catch,
# log, count as failed, move on.
try {
if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) {
Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping"
$pcFiltered++
continue
}
if (-not (Test-HostnameMatches -App $app)) {
Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $([System.Environment]::MachineName) - skipping"
$pcFiltered++
continue
}
if (-not (Test-MachineNumberMatches -App $app)) {
$myNum = Get-CurrentMachineNumber
Write-InstallLog " TargetMachineNumbers filter: entry targets $($app.TargetMachineNumbers -join ',') but machine number is $(if ($myNum) { $myNum } else { '(none)' }) - skipping"
$pcFiltered++
continue
}
if (Test-AppInstalled -App $app) {
Write-InstallLog ' Already installed at expected version - skipping'
$skipped++
continue
}
# --- InUseCheck (partial Stage 2b): ForceClose / CloseAndReopen ---
# Before install, if the entry declares processes that would block the
# install, close them. Stage 2a supports ForceClose: polite WM_CLOSE
# with a timeout then hard Kill. "CloseAndReopen" is currently treated
# the same (Stage 2b will add the user-session relaunch trick). No
# reopen happens today; operator relaunches the app or the enforcer
# leaves it for Windows Explorer / Start-Menu shortcut behavior.
if ($app.InUseCheck -and $app.InUseCheck.Behavior -in @('ForceClose','CloseAndReopen')) {
foreach ($p in ($app.InUseCheck.Processes | Where-Object { $_ })) {
$procs = Get-Process -Name $p.Name -ErrorAction SilentlyContinue
foreach ($proc in $procs) {
$timeout = if ($p.GracefulCloseTimeoutSec) { [int]$p.GracefulCloseTimeoutSec } else { 10 }
try {
Write-InstallLog " InUseCheck: $($p.Name) (PID $($proc.Id)) asked to close (timeout ${timeout}s)"
$null = $proc.CloseMainWindow()
if (-not $proc.WaitForExit([int]($timeout * 1000))) {
Write-InstallLog " InUseCheck: $($p.Name) did not exit gracefully - killing" 'WARN'
$proc.Kill()
$proc.WaitForExit(5000) | Out-Null
}
} catch {
Write-InstallLog " InUseCheck: close/kill of $($p.Name) threw: $_" 'WARN'
}
}
}
}
$result = Invoke-InstallerAction -App $app
$rc = $result.ExitCode
if ($rc -eq 0 -or $rc -eq 1641 -or $rc -eq 3010 -or $rc -eq 259) {
Write-InstallLog " Exit $rc - SUCCESS"
if ($rc -eq 3010) { Write-InstallLog " (Reboot pending for $($app.Name))" }
if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" }
if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' }
$installed++
# Auto-write marker file for MarkerFile-detected entries that just
# completed successfully. Keeps one-shot PS1 scripts from running
# twice (idempotent scripts can skip this by using Always detection).
if ($app.DetectionMethod -eq 'MarkerFile' -and $app.DetectionPath) {
$markerDir = Split-Path -Parent $app.DetectionPath
if ($markerDir -and -not (Test-Path $markerDir)) {
New-Item -Path $markerDir -ItemType Directory -Force | Out-Null
}
try {
Set-Content -Path $app.DetectionPath -Value (Get-Date -Format 'o') -ErrorAction Stop
Write-InstallLog " marker written: $($app.DetectionPath)"
} catch {
Write-InstallLog " marker write failed: $_" 'WARN'
}
}
}
else {
Write-InstallLog " Exit $rc - FAILED" 'ERROR'
if ($result.LogRef -and (Test-Path $result.LogRef)) {
if ($app.Type -eq 'MSI') {
Write-InstallLog " --- meaningful lines from $($result.LogRef) ---"
$patterns = @(
'Note: 1: ', 'return value 3', 'Error \d+\.',
'CustomAction .* returned actual error', 'Failed to ',
'Installation failed', '1: 2262', '1: 2203', '1: 2330'
)
$regex = ($patterns -join '|')
$matches = Select-String -Path $result.LogRef -Pattern $regex -ErrorAction SilentlyContinue | Select-Object -First 30
if ($matches) {
foreach ($m in $matches) { Write-InstallLog " $($m.Line.Trim())" }
} else {
Get-Content $result.LogRef -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" }
}
Write-InstallLog ' --- end MSI log scan ---'
} else {
Write-InstallLog " --- last 30 lines of $($result.LogRef) ---"
Get-Content $result.LogRef -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" }
Write-InstallLog ' --- end installer log tail ---'
}
}
$failed++
}
} catch {
Write-InstallLog (" UNCAUGHT error processing {0}: {1} | at {2}" -f $app.Name, $_.Exception.Message, ($_.ScriptStackTrace -replace '\s+',' ')) 'ERROR'
$failed++
}
}
Write-InstallLog '============================================'
Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed, $pcFiltered pc-filtered"
Write-InstallLog '============================================'
cmd /c 'shutdown /a 2>nul' *>$null
if ($failed -gt 0) { exit 1 }
exit 0