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>
248 lines
11 KiB
PowerShell
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
|