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>
306 lines
14 KiB
PowerShell
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
|