Files
pxe-server/playbook/shopfloor-setup/common/GE-Enforce.ps1
cproudlock 707a0f94c2 GE-Enforce: prefer DNC reg MachineNo over machine-number.txt
machine-number.txt holds the imaging-time MN. PCs imaged with
placeholder 9999 (tech intends to flip via Set-MachineNumber later)
keep 9999 in that file even after Update-MachineNumber writes the
real MN to HKLM:\...\Dnc\General\MachineNo. Status.json was reporting
9999 across the fleet because of this.

Now reads DNC reg first; only falls back to machine-number.txt if reg
is missing or also 9999. Existing convergence-check.txt unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-04 17:12:55 -04:00

330 lines
15 KiB
PowerShell

# GE-Enforce.ps1 - Unified shopfloor enforcer.
#
# Replaces the per-type enforcers (CMM-Enforce, Keyence-Enforce,
# Machine-Enforce, Common-Enforce, Acrobat-Enforce). Runs as a single SYSTEM
# scheduled task with multiple triggers (at-logon, periodic 5-min, and the
# three shift-change windows).
#
# On each invocation:
# 1. Reads C:\Enrollment\pc-type.txt and pc-subtype.txt.
# 2. Mounts the tsgwp00525 SFLD share using creds from HKLM.
# 3. Processes common\manifest.json (always, with per-entry PCTypes filter).
# 4. Processes <pcType>\manifest.json if it exists (enforces type-specific
# apps like CMM, Keyence, Display, Keyence, etc.).
# 5. Processes <pcType>-<subType>\manifest.json if it exists
# (the "standard-machine" case).
#
# Graceful degradation:
# - pc-type.txt missing -> log + exit 0 (PC is pre-imaging)
# - SFLD creds missing -> log + exit 0 (Azure DSC hasn't provisioned yet)
# - Share unreachable -> log + exit 0 (off-network, retry next cycle)
# - Per-entry install failure -> log + continue to next entry
#
# Always exits 0 so the scheduled task "last run result" stays clean. Truth
# is in the log: C:\Logs\Shopfloor\enforce-YYYYMMDD.log.
$ErrorActionPreference = 'Continue'
$installRoot = 'C:\Program Files\GE\Shopfloor'
$libPath = Join-Path $installRoot 'lib\Install-FromManifest.ps1'
$logDir = 'C:\Logs\Shopfloor'
$logFile = Join-Path $logDir ('enforce-{0}.log' -f (Get-Date -Format 'yyyyMMdd'))
$driveLetter = 'W:' # distinct from S: (shopfloor share), T:/U: (legacy enforcers)
if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
}
function Write-EnforceLog {
param([string]$Message, [string]$Level = 'INFO')
$line = '[{0}] [{1}] {2}' -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Level, $Message
Write-Host $line
Add-Content -Path $logFile -Value $line -ErrorAction SilentlyContinue
}
Write-EnforceLog '================================================================'
Write-EnforceLog "=== GE-Enforce session start (PID $PID, user $env:USERNAME) ==="
Write-EnforceLog '================================================================'
# --- Log retention prune ---
# Drops *.log files older than $retentionDays from the shopfloor log roots.
# Cheap (flat dir scan, no recursion). Runs every cycle. Today's
# enforce-YYYYMMDD.log is never touched (LastWriteTime = now).
$retentionDays = 30
$prunedCount = 0
foreach ($root in @('C:\Logs\Shopfloor', 'C:\Logs\SFLD', 'C:\Logs\Keyence')) {
if (-not (Test-Path $root)) { continue }
$cutoff = (Get-Date).AddDays(-$retentionDays)
Get-ChildItem -Path $root -Filter '*.log' -File -ErrorAction SilentlyContinue |
Where-Object { $_.LastWriteTime -lt $cutoff } |
ForEach-Object {
try { Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop; $prunedCount++ } catch {}
}
}
if ($prunedCount -gt 0) {
Write-EnforceLog "Pruned $prunedCount log file(s) older than $retentionDays days"
}
# --- pc-type ---
$pcTypeFile = 'C:\Enrollment\pc-type.txt'
$pcSubTypeFile = 'C:\Enrollment\pc-subtype.txt'
if (-not (Test-Path $pcTypeFile)) {
Write-EnforceLog "pc-type.txt not found - PC is pre-imaging, nothing to enforce"
exit 0
}
$pcType = (Get-Content -LiteralPath $pcTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
$pcSubType = if (Test-Path $pcSubTypeFile) {
(Get-Content -LiteralPath $pcSubTypeFile -First 1 -ErrorAction SilentlyContinue).Trim()
} else { '' }
Write-EnforceLog "PCType: $pcType$(if ($pcSubType) { " / $pcSubType" })"
# --- site-config ---
$siteConfigFile = 'C:\Enrollment\site-config.json'
if (-not (Test-Path $siteConfigFile)) {
Write-EnforceLog "site-config.json not found at $siteConfigFile" 'ERROR'
exit 0
}
try {
$siteConfig = Get-Content -LiteralPath $siteConfigFile -Raw | ConvertFrom-Json
} catch {
Write-EnforceLog "site-config.json parse failed: $_" 'ERROR'
exit 0
}
$shopfloorShareRoot = $siteConfig.shopfloorShareRoot
if (-not $shopfloorShareRoot) {
# Fallback: derive from commonAppsSharePath if the new field isn't set.
$capp = $siteConfig.common.commonAppsSharePath
if ($capp) {
# \\...\shared\dt\shopfloor\common\apps -> \\...\shared\dt\shopfloor
$shopfloorShareRoot = ($capp -replace '\\common\\apps$', '')
}
}
if (-not $shopfloorShareRoot) {
Write-EnforceLog 'No shopfloorShareRoot derivable from site-config - nothing to enforce' 'ERROR'
exit 0
}
Write-EnforceLog "Shopfloor share root: $shopfloorShareRoot"
# --- SFLD credential lookup (written by Azure DSC) ---
function Get-SFLDCredential {
param([string]$ServerName)
$basePath = 'HKLM:\SOFTWARE\GE\SFLD\Credentials'
if (-not (Test-Path $basePath)) { return $null }
foreach ($entry in Get-ChildItem -Path $basePath -ErrorAction SilentlyContinue) {
$props = Get-ItemProperty -Path $entry.PSPath -ErrorAction SilentlyContinue
if (-not $props -or -not $props.TargetHost) { continue }
if ($props.TargetHost -eq $ServerName -or
$props.TargetHost -like "$ServerName.*" -or
$ServerName -like "$($props.TargetHost).*") {
return @{
Username = $props.Username
Password = $props.Password
TargetHost = $props.TargetHost
KeyName = $entry.PSChildName
}
}
}
return $null
}
$serverName = ($shopfloorShareRoot -replace '^\\\\', '') -split '\\' | Select-Object -First 1
$cred = Get-SFLDCredential -ServerName $serverName
if (-not $cred -or -not $cred.Username -or -not $cred.Password) {
Write-EnforceLog "No SFLD credential for $serverName yet (Azure DSC has not provisioned it) - will retry next cycle"
exit 0
}
Write-EnforceLog "Credential: $($cred.KeyName) (user: $($cred.Username))"
# --- Mount ---
& net use $driveLetter /delete /y 2>$null | Out-Null
$netResult = & net use $driveLetter $shopfloorShareRoot /user:$($cred.Username) $($cred.Password) /persistent:no 2>&1
if ($LASTEXITCODE -ne 0) {
Write-EnforceLog "net use failed (exit $LASTEXITCODE): $netResult" 'WARN'
Write-EnforceLog 'Share unreachable - probably off-network. Will retry at next cycle.'
exit 0
}
Write-EnforceLog "Mounted $shopfloorShareRoot as $driveLetter"
try {
if (-not (Test-Path $libPath)) {
Write-EnforceLog "Install-FromManifest.ps1 not found at $libPath" 'ERROR'
return
}
# --- Process manifests in order: common, per-type, per-type+subtype ---
# ---- Manifest dir resolution with alias support ---------------
# The 2026-05-03 rename reorg replaced legacy dir names (standard-machine,
# cmm, keyence, ...) with gea-shopfloor-* equivalents on the share. Fleet
# PCs may still write old names to pc-type.txt during transition. Try
# the constructed dir first; if it has no manifest.json, walk the alias
# set and pick the first that does. See project-shopfloor-rename-reorg
# memory note for the full rename plan.
$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')
)
function Resolve-ManifestDir {
param([string]$DirName)
$primary = Join-Path $driveLetter $DirName
if (Test-Path (Join-Path $primary 'manifest.json')) { return $primary }
foreach ($g in $pcTypeAliasGroups) {
if ($g -contains $DirName.ToLower()) {
foreach ($alias in $g) {
if ($alias -ieq $DirName) { continue }
$candidate = Join-Path $driveLetter $alias
if (Test-Path (Join-Path $candidate 'manifest.json')) { return $candidate }
}
}
}
return $null
}
$targets = @()
$commonRoot = Join-Path $driveLetter 'common'
$commonManifest = Join-Path $commonRoot 'manifest.json'
if (Test-Path $commonManifest) {
$targets += [pscustomobject]@{ Label = 'common'; Manifest = $commonManifest; Root = $commonRoot }
} else {
Write-EnforceLog "common\manifest.json missing - skipping common scope" 'WARN'
}
$typeDir = $pcType.ToLower()
if ($typeDir) {
$resolvedRoot = Resolve-ManifestDir -DirName $typeDir
if ($resolvedRoot) {
$typeManifest = Join-Path $resolvedRoot 'manifest.json'
$targets += [pscustomobject]@{ Label = $pcType; Manifest = $typeManifest; Root = $resolvedRoot }
} else {
Write-EnforceLog "$typeDir\manifest.json (or aliases) not on share - no type-specific apps for $pcType"
}
}
if ($pcSubType) {
$stDir = ($pcType + '-' + $pcSubType).ToLower()
$resolvedRoot = Resolve-ManifestDir -DirName $stDir
if ($resolvedRoot) {
$stManifest = Join-Path $resolvedRoot 'manifest.json'
$targets += [pscustomobject]@{ Label = "$pcType-$pcSubType"; Manifest = $stManifest; Root = $resolvedRoot }
}
}
$scopeResults = @()
foreach ($t in $targets) {
Write-EnforceLog "---- Processing scope: $($t.Label) ----"
Write-EnforceLog " Manifest: $($t.Manifest)"
Write-EnforceLog " Root: $($t.Root)"
& $libPath -ManifestPath $t.Manifest -InstallerRoot $t.Root -LogFile $logFile -PCType $pcType -PCSubType $pcSubType
$rc = $LASTEXITCODE
Write-EnforceLog " Install-FromManifest returned $rc"
$scopeResults += [pscustomobject]@{ Label = $t.Label; ExitCode = $rc }
}
# ------------------------------------------------------------------
# Status write-back to _outputs/logs/<hostname>/status.json on share.
# Consumed by the management app / fleet dashboard to answer
# "which PCs checked in, when, and what version did each install."
# Writes under the mounted drive using SFLD creds; gracefully
# continues if the share path is not writable.
# ------------------------------------------------------------------
try {
# Live NetBIOS name from kernel - not $env:COMPUTERNAME, which is
# cached in the process env block and goes stale after a post-image
# rename on Intune-managed PCs.
$hostname = [System.Environment]::MachineName
$statusDir = Join-Path (Join-Path $driveLetter '_outputs') (Join-Path 'logs' $hostname)
if (-not (Test-Path $statusDir)) {
New-Item -Path $statusDir -ItemType Directory -Force -ErrorAction Stop | Out-Null
}
$statusFile = Join-Path $statusDir 'status.json'
# Walk each processed manifest and pull current DetectionValue per entry.
$installedVersions = @{}
foreach ($t in $targets) {
try {
$cfg = Get-Content -LiteralPath $t.Manifest -Raw | ConvertFrom-Json
foreach ($a in $cfg.Applications) {
if (-not $a.DetectionPath) { continue }
$key = "$($t.Label)/$($a.Name)"
$val = $null
switch ($a.DetectionMethod) {
'Registry' {
if ((Test-Path $a.DetectionPath) -and $a.DetectionName) {
$p = Get-ItemProperty -Path $a.DetectionPath -Name $a.DetectionName -ErrorAction SilentlyContinue
if ($p) { $val = "$($p.$($a.DetectionName))" }
}
}
'FileVersion' {
if (Test-Path $a.DetectionPath) {
$val = (Get-Item $a.DetectionPath).VersionInfo.FileVersion
}
}
'File' { if (Test-Path $a.DetectionPath) { $val = 'present' } }
'Hash' { if (Test-Path $a.DetectionPath) { $val = (Get-FileHash -Path $a.DetectionPath -Algorithm SHA256).Hash } }
'MarkerFile' { if (Test-Path $a.DetectionPath) { $val = 'marker present' } }
'Always' { $val = 'n/a (Always)' }
'pnputil' { $val = 'pnputil-managed' }
}
$installedVersions[$key] = $val
}
} catch {
Write-EnforceLog " status: manifest introspection for $($t.Label) failed: $_" 'WARN'
}
}
# Prefer DNC reg (authoritative post-Update-MachineNumber) over
# machine-number.txt (imaging-time placeholder, often 9999 if tech
# imaged with placeholder + bay assignment came later).
$machineNumber = ''
try {
$regPaths = @(
'HKLM:\SOFTWARE\WOW6432Node\GE Aircraft Engines\Dnc\General',
'HKLM:\SOFTWARE\GE Aircraft Engines\Dnc\General'
)
foreach ($rp in $regPaths) {
if (Test-Path $rp) {
$v = (Get-ItemProperty -Path $rp -Name MachineNo -ErrorAction SilentlyContinue).MachineNo
if ($v -and $v -ne '9999') { $machineNumber = "$v"; break }
}
}
# Fall back to enrollment file (will be 9999 for placeholder PCs)
if (-not $machineNumber) {
if (Test-Path 'C:\Enrollment\machine-number.txt') {
$machineNumber = (Get-Content 'C:\Enrollment\machine-number.txt' -First 1 -ErrorAction SilentlyContinue).Trim()
}
}
} catch {}
$status = [ordered]@{
hostname = $hostname
machineNumber = $machineNumber
lastCheckIn = (Get-Date).ToUniversalTime().ToString('o')
pcType = $pcType
pcSubType = $pcSubType
enforcerVersion = '2.5'
shopfloorShareRoot = $shopfloorShareRoot
scopesProcessed = $scopeResults
installedVersions = $installedVersions
} | ConvertTo-Json -Depth 6
Set-Content -Path $statusFile -Value $status -Encoding ascii -ErrorAction Stop
Write-EnforceLog "Status written to $statusFile"
} catch {
Write-EnforceLog "Status write-back failed (share may be read-only for this user): $_" 'WARN'
}
}
finally {
& net use $driveLetter /delete /y 2>$null | Out-Null
Write-EnforceLog "Unmounted $driveLetter"
Write-EnforceLog '=== GE-Enforce session end ==='
}
exit 0