Files
pxe-server/playbook/shopfloor-setup/common/GE-Enforce.ps1
cproudlock 3cb79715bf GE-Enforce: bump enforcerVersion to 2.4 in status.json write-back
Hardcoded version string surfaced in _outputs/logs/<host>/status.json
that the fleet check-in dashboard reads. Bump aligned with
Install-FromManifest lib v2.4 (PCTypes alias map + network-share EXE
staging). Lets the convergence-check oneliner distinguish PCs that
have picked up the post-rename dispatcher from those still on 2.0.

When pushed to share + the self-update common/manifest.json entry's
DetectionValue is bumped to the new SHA256 (commit notes record the
hash but the manifest itself lives on the v2 share, not in this repo
by design), every fleet PC's next cycle re-fires the self-update,
copies new bytes locally, the cycle after that writes status.json
with enforcerVersion=2.4. Fully visible in the dashboard read.

new GE-Enforce.ps1 SHA256:
  C8C14CFCE539ACDC6D16B31D2C456A5239516BDFA1EBFC820794A2D58BA7D9AC

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

306 lines
14 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'
}
}
$status = [ordered]@{
hostname = $hostname
lastCheckIn = (Get-Date).ToUniversalTime().ToString('o')
pcType = $pcType
pcSubType = $pcSubType
enforcerVersion = '2.4'
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