Lib v2.4. Process.Start of an EXE that lives on a network share fails with "Access is denied" when the dispatcher runs as SYSTEM, even when the share is properly mounted via cmdkey + net use. Empirically confirmed 2026-05-02 with UDC_Setup.exe via qga. Fix: when the resolved EXE path is on a UNC or PSDrive-with-DisplayRoot mount, copy the file into a per-cycle temp dir under $env:TEMP and run from there. Cleanup happens in finally regardless of run outcome. Cost is one transit per fire, which is rare in practice because most EXE entries skip on subsequent cycles via DetectionMethod. Validated on win11 VM with UDC_Setup.exe: dispatcher previously returned blank exit code with "Access is denied" in stderr; now logs "staged network EXE -> C:\WINDOWS\TEMP\ge-enforce-exe-..." and the process runs to Exit 0 in ~18 seconds. UDC's separate "exit 0 without actually installing" issue is a wrong-silent-flag in InstallArgs, not this dispatcher fix. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
661 lines
31 KiB
PowerShell
661 lines
31 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.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 = 4
|
|
|
|
$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)" }
|
|
try {
|
|
$proc = [System.Diagnostics.Process]::Start($psi)
|
|
$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' {
|
|
$scriptPath = Join-Path $InstallerRoot ($App.Script)
|
|
if (-not (Test-Path -LiteralPath $scriptPath)) {
|
|
Write-InstallLog " PS1 not found: $scriptPath" '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')
|
|
)
|
|
|
|
# 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 value the tech
|
|
# entered at the PXE menu, persisted to C:\Enrollment\machine-number.txt
|
|
# by startnet.cmd. Falls back to the DNC registry if that file is missing
|
|
# (covers PCs that pre-date this filter being introduced).
|
|
$script:_cachedMachineNumber = $null
|
|
function Get-CurrentMachineNumber {
|
|
if ($null -ne $script:_cachedMachineNumber) { return $script:_cachedMachineNumber }
|
|
$candidates = @(
|
|
'C:\Enrollment\machine-number.txt'
|
|
)
|
|
foreach ($p in $candidates) {
|
|
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 }
|
|
}
|
|
}
|
|
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) { $script:_cachedMachineNumber = [string]$p.MachineNo; 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)"
|
|
|
|
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++
|
|
}
|
|
}
|
|
|
|
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
|