Files
pxe-server/playbook/shopfloor-setup/common/GE-Enforce.ps1
cproudlock b075939c38 GE-Enforce: roll undated logs so retention can actually reach them
The prune drops a *.log by LastWriteTime. A log written to a fixed filename is
appended every cycle, so its LastWriteTime is always now, it can never be older
than any cutoff, and it grows without limit. ntlars-backup.log, eventsaver.log
and shopdb-collector-key.log all do this. On one part-marker PC eventsaver.log
had reached 11,000 lines and ntlars-backup.log 3,234, of which 3,217 were the
same "Throttled" line - the four events that mattered were unfindable without
grep -v.

Undated logs are now rolled to <name>-YYYYMMDD.log before the prune runs, which
stops them being written to and lets them age out on the existing 30 day
retention. The owning scripts need no change: they append with Add-Content or
Tee-Object, which recreate a missing file on the next write. This matters
because those three scripts are live-share artifacts, not in this repo.

Rolled under the date of its FIRST LINE, so the stamp matches the contents.
That is also the only trustworthy signal: CreationTime is not, because NTFS
file tunneling gives a recreated file the old creation time when it reappears
within 15 seconds, so a busy log keyed on that would look stale the instant it
rolled and would roll again every cycle.

A log with no parseable timestamp is left alone until it passes 5MB, so an
unrecognised format still cannot grow forever. Empty files and already-stamped
files are skipped. Rolling onto an existing target appends rather than
overwriting, so a second roll on one day loses neither side.

Verified against real files under pwsh, including the append branch and the
owner recreating the file afterwards. The first attempt keyed on CreationTime
and a second used [datetime]::TryParse with an untyped $null, which throws "no
overload" and would have made the whole roll a silent no-op; the ref is now
pre-typed.
2026-08-11 09:03:28 -04:00

436 lines
20 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 '================================================================'
$logRoots = @('C:\Logs\Shopfloor', 'C:\Logs\SFLD', 'C:\Logs\Keyence')
# --- Roll undated logs so they can age ---
# The prune below drops a *.log by LastWriteTime. A log written to a FIXED
# filename is appended every cycle, so its LastWriteTime is always now and it
# can never be older than any cutoff - it grows forever. ntlars-backup.log,
# eventsaver.log and shopdb-collector-key.log all do this; eventsaver.log had
# reached 11,000 lines on a single PC.
#
# Rolling one to <name>-YYYYMMDD.log stops it being written to, so the prune
# takes it 30 days later, and the script that owns it needs no change: they all
# append with Add-Content or Tee-Object, which recreate a missing file on the
# next write. A file already carrying a date stamp is left alone.
#
# Rolled under the date of its FIRST LINE, so the stamp matches the lines
# inside. That timestamp is also the only trustworthy signal available:
# CreationTime is not, because of NTFS file tunneling - rename a file away and
# let the owner recreate it within 15 seconds, and the new file INHERITS the
# old creation time. Keyed on that, a busy log would look stale the moment it
# was rolled and would roll again every cycle, forever.
$rolledCount = 0
$today = (Get-Date).Date
foreach ($root in $logRoots) {
if (-not (Test-Path $root)) { continue }
Get-ChildItem -Path $root -Filter '*.log' -File -ErrorAction SilentlyContinue |
Where-Object { $_.BaseName -notmatch '\d{8}$' -and $_.Length -gt 0 } |
ForEach-Object {
$firstline = Get-Content -LiteralPath $_.FullName -TotalCount 1 -ErrorAction SilentlyContinue
# MUST be pre-typed. TryParse takes [ref][datetime], and passing a
# [ref] to an untyped $null throws "cannot find an overload", which
# would make this whole roll a silent no-op.
$started = [datetime]::MinValue
if ($firstline -match '(\d{4}-\d{2}-\d{2})') {
[void][datetime]::TryParse($matches[1], [ref]$started)
}
# No parseable date: fall back to size so a log with a format we do
# not recognise still cannot grow without limit.
if ($started -eq [datetime]::MinValue) {
if ($_.Length -lt 5MB) { return }
$started = $today.AddDays(-1)
}
if ($started.Date -ge $today) { return }
$stamp = $started.ToString('yyyyMMdd')
$target = Join-Path $_.DirectoryName ('{0}-{1}.log' -f $_.BaseName, $stamp)
# An existing target means the roll already ran for that day; append
# to it rather than losing either side.
try {
if (Test-Path -LiteralPath $target) {
Get-Content -LiteralPath $_.FullName -ErrorAction Stop |
Add-Content -LiteralPath $target -ErrorAction Stop
Remove-Item -LiteralPath $_.FullName -Force -ErrorAction Stop
} else {
Move-Item -LiteralPath $_.FullName -Destination $target -ErrorAction Stop
}
$rolledCount++
} catch {}
}
}
if ($rolledCount -gt 0) {
Write-EnforceLog "Rolled $rolledCount undated log file(s) so retention can age them"
}
# --- 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 $logRoots) {
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 { '' }
# Backfill pc-subtype.txt on Keyence PCs imaged before 2026-05 (startnet.cmd
# didn't write pc-subtype.txt for Keyence then). Without a subtype, the share
# manifest's per-model PCTypes gate falls back to installing the default model
# (VR-6000) on top of VR-3000 / VR-5000 boxes. Detect the installed model from
# its uninstall ProductCode and persist the subtype so subsequent GE-Enforce
# cycles + the share manifest gate route correctly.
if ($pcType -ieq 'keyence' -and -not $pcSubType) {
$keyenceProducts = @(
@{ Subtype = 'vr3000'; Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{9CC9A062-2A93-4D3B-AECA-F70C691A46F2}' },
@{ Subtype = 'vr5000'; Path = 'HKLM:\SOFTWARE\WOW6432Node\Microsoft\Windows\CurrentVersion\Uninstall\{AF7E8B93-DBEB-4DB1-91CB-4DA592D8E222}' },
@{ Subtype = 'vr6000'; Path = 'HKLM:\SOFTWARE\Microsoft\Windows\CurrentVersion\Uninstall\{058E7194-BDF8-4FA2-9D69-978BB0F25214}' }
)
foreach ($p in $keyenceProducts) {
if (Test-Path -LiteralPath $p.Path) {
$pcSubType = $p.Subtype
try {
$enrollDir = Split-Path -Parent $pcSubTypeFile
if (-not (Test-Path -LiteralPath $enrollDir)) {
New-Item -Path $enrollDir -ItemType Directory -Force | Out-Null
}
Set-Content -LiteralPath $pcSubTypeFile -Value $pcSubType -Encoding ascii -Force
Write-EnforceLog "Backfilled pc-subtype.txt = $pcSubType from installed product code"
} catch {
Write-EnforceLog "pc-subtype.txt backfill write failed: $_" 'WARN'
}
break
}
}
if (-not $pcSubType) {
Write-EnforceLog "Keyence PC with no pc-subtype.txt and no recognized VR product installed - skipping model-gated apps until imaging populates subtype" 'WARN'
}
}
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 {
# Re-mount W: before status write-back. Long-running entries (UDC
# WaitTimeoutSec=120) can let the SMB session time out idle, leaving
# W: as a dead drive letter that fails downstream Path operations
# with confusing "argument is null" errors. Cheap to re-attach.
& net use $driveLetter /delete /y 2>$null | Out-Null
& net use $driveLetter $shopfloorShareRoot /user:$($cred.Username) $($cred.Password) /persistent:no 2>&1 | Out-Null
# 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
if (-not $hostname) { $hostname = 'UNKNOWN' }
if (-not $driveLetter) { throw 'driveLetter unset before status write-back' }
$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.1'
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