Files
pxe-server/playbook/shopfloor-setup/common/GE-Enforce.ps1
cproudlock eb68793e79 Stage 2a: unified GE-Enforce framework + share-root mirror
Consolidates per-type enforcers (CMM, Keyence, Machine, Common, Acrobat)
into one dispatcher driven by pc-type.txt + site-config and a share-side
manifest layout. Same share is now the single source of truth for routine
software updates without re-imaging.

Runtime:
  common/GE-Enforce.ps1           SYSTEM scheduled task. Reads
                                   common/manifest.json plus optional
                                   <pcType>/manifest.json and
                                   <pcType-subType>/manifest.json.
                                   Dispatches each entry through the lib.
                                   Writes _outputs/logs/<hostname>/status.json
                                   on the share after each cycle for fleet
                                   monitoring.
  common/Register-GEEnforce.ps1   Task registration. Triggers: AtLogOn +
                                   every 5 min (jittered per-PC from
                                   hostname hash) + daily at 05:45,
                                   13:45, 21:45 EST shift windows.
                                   Unregisters legacy per-type tasks on
                                   install so the two coexist at most for
                                   the duration of a single enforce cycle.
  common/Deploy-GEEnforce.ps1     Retrofit helper for already-imaged PCs
                                   (admin-run; copies runtime + registers
                                   task + optional immediate trigger).

Library (common/lib/Install-FromManifest.ps1):
  - New Type values: PS1, BAT, File, Registry, INF
  - New DetectionMethod values: Always, MarkerFile, ValueMatches, pnputil
  - TargetHostnames filter (exact + -like wildcards, ANDed with PCTypes)
  - Schema version check (logs WARN on manifest newer than lib MAJOR)
  - Auto-writes MarkerFile on successful one-shot PS1/BAT/CMD runs
  - MSI log scan on failure surfaces meaningful install errors
  - Lib version bumped 2.0 -> 2.1 for TargetHostnames

Observability:
  common/monitor-fleet-status.py  Scans _outputs/logs/*/status.json for
                                   stale check-ins, failed scopes, and
                                   version drift. Respects scope (dir-name),
                                   PCTypes, and TargetHostnames filters so
                                   entries excluded from a PC do not
                                   false-flag as drift.

Regression harness:
  common/test/                    Parameterized VM harness + README
                                   covering every action type plus
                                   rollback, bad/missing SFLD creds, and
                                   schema versioning.

Imaging integration:
  Run-ShopfloorSetup.ps1 now stages GE-Enforce.ps1 and lib to
  C:\Program Files\GE\Shopfloor\ and invokes Register-GEEnforce.ps1
  at the end of setup. Legacy Register-CommonEnforce invocation is
  kept for the transition; it and the legacy per-type enforcer files
  are dead code once Register-GEEnforce runs and will be removed in a
  dedicated cleanup pass.

Standard-Machine manifest:
  eDNC entry bumped 6.4.3 -> 6.4.5. DetectionValue pinned to the
  4-part FileVersion 6.4.5.0 verified against a fresh install in the
  Win11 analyzer VM. UDC DetectionValue pinned to 1.0.34 (registry
  stores 3-part for UDC; verified live).

scripts/mirror-from-gold.sh:
  Restructured around share-root rsyncs (one pass per Samba share)
  to close gaps in the prior per-subdir layout: winpeapps/_shared/
  Applications (7.5 GB of Adobe + fonts + Java + Office + OpenText
  + printdrivers + wireless + Zscaler), additional winpeapps image
  types, and enrollment flat-layout root files. Adds
  --skip-clonezilla and --skip-reports.

Verified end-to-end in the Win11 analyzer VM:
  - Every action Type and DetectionMethod round-tripped
  - PCTypes filter (Oracle excluded on Shopfloor, Firefox included
    on Shopfloor and DESKTOP-*, excluded elsewhere)
  - TargetHostnames filter (exact, wildcard, no-match)
  - Upgrade path: XML hash bump + fleet re-copy
  - Rollback path: history-archive restore propagates via enforcer,
    fleet converges back without per-PC intervention
  - Status writeback + monitor script drift detection
  - Graceful degradation on bad creds, missing creds, share
    unreachable (all exit 0, log clearly, retry next cycle)

Not in this commit (follow-ups):
  - Retire legacy per-type *-Enforce.ps1 files and simplify
    09-Setup-*.ps1 scripts (coordinated multi-file cleanup)
  - Stage 2b: InUseCheck close-and-reopen, ApplyMode gating,
    UpdateWindow, .apply-now.txt sentinel, BITS pre-staging,
    1618 mutex retry, PostInstallCheck, Uninstall action
  - Management app (manifest CRUD + deploy + rollback + fleet view)
  - ShopFloor autologon persistence bug (deferred for next imaging
    attempt with live registry evidence)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-22 11:19:23 -04:00

248 lines
11 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 '================================================================'
# --- 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 ---
$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) {
$typeRoot = Join-Path $driveLetter $typeDir
$typeManifest = Join-Path $typeRoot 'manifest.json'
if (Test-Path $typeManifest) {
$targets += [pscustomobject]@{ Label = $pcType; Manifest = $typeManifest; Root = $typeRoot }
} else {
Write-EnforceLog "$typeDir\manifest.json not on share - no type-specific apps for $pcType"
}
}
if ($pcSubType) {
$stDir = ($pcType + '-' + $pcSubType).ToLower()
$stRoot = Join-Path $driveLetter $stDir
$stManifest = Join-Path $stRoot 'manifest.json'
if (Test-Path $stManifest) {
$targets += [pscustomobject]@{ Label = "$pcType-$pcSubType"; Manifest = $stManifest; Root = $stRoot }
}
}
$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 {
$statusDir = Join-Path (Join-Path $driveLetter '_outputs') (Join-Path 'logs' $env:COMPUTERNAME)
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 = $env:COMPUTERNAME
lastCheckIn = (Get-Date).ToUniversalTime().ToString('o')
pcType = $pcType
pcSubType = $pcSubType
enforcerVersion = '2.0'
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