Files
pxe-server/playbook/shopfloor-setup/common/lib/Install-FromManifest.ps1
cproudlock a351160520 manifest engine: reg-first machine number + per-app crash isolation; add start-layout DSC
Install-FromManifest.ps1:
- Get-CurrentMachineNumber reads the eDNC/DNC registry FIRST (reassignment-
  authoritative), falling back to C:\Enrollment\machine-number.txt. The txt is
  written once at imaging and is NOT updated on reassignment, so txt-first
  gated reassigned bays on a stale number.
- Per-entry try/catch in the app loop: a single entry that throws no longer
  aborts the whole scope (skipping every later entry + the status write). It is
  logged, counted failed, and the loop continues. This was silently killing the
  collections scope at the MTConnect Makino entry, which also stopped the
  ShopDB asset reporter (a later entry) from ever running.

Deploy-ShopfloorStartLayout.ps1 (new): local-DSC port of the Intune
desktop-weblinks + Start-menu pins (copies .url/.lnk to Public Desktop +
All-Users Start Menu, writes the ConfigureStartPins JSON policy, resets
start2.bin + restarts the shell). Verified on Win11: pins render after logon.

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

710 lines
33 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' {
$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'),
@('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