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>
This commit is contained in:
cproudlock
2026-04-22 11:19:23 -04:00
parent 6dcf832ace
commit eb68793e79
10 changed files with 1266 additions and 243 deletions

View File

@@ -286,13 +286,47 @@ Unregister-ScheduledTask -TaskName 'GE Re-enable Wired NICs' -Confirm:$false -Er
} }
$commonSetupDir = Join-Path $PSScriptRoot 'common' $commonSetupDir = Join-Path $PSScriptRoot 'common'
# --- Register the unified GE-Enforce scheduled task ---
# Replaces the per-type legacy enforcers (CMM-Enforce, Keyence-Enforce,
# Machine-Enforce, Common-Enforce, Acrobat-Enforce). Register-GEEnforce.ps1
# unregisters any of those legacy tasks before creating the new one, so
# running this after the legacy Register-* invocations below is harmless
# and race-free. Once a future repo cleanup retires the legacy Register-*
# scripts entirely, those invocations below can be removed. Until then we
# accept a brief moment of duplicate registration that Register-GEEnforce
# itself resolves.
$registerGE = Join-Path $commonSetupDir 'Register-GEEnforce.ps1'
if (Test-Path -LiteralPath $registerGE) {
Write-Host ""
Write-Host "=== Registering unified GE Shopfloor enforcer ==="
try {
$enforcerRuntime = Join-Path $commonSetupDir 'GE-Enforce.ps1'
$libSource = Join-Path $commonSetupDir 'lib\Install-FromManifest.ps1'
# Stage enforcer runtime so the scheduled task can reach it post-imaging.
$runtimeDir = 'C:\Program Files\GE\Shopfloor'
$runtimeLib = Join-Path $runtimeDir 'lib'
foreach ($d in @($runtimeDir, $runtimeLib)) {
if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null }
}
Copy-Item -LiteralPath $enforcerRuntime -Destination (Join-Path $runtimeDir 'GE-Enforce.ps1') -Force
Copy-Item -LiteralPath $libSource -Destination (Join-Path $runtimeLib 'Install-FromManifest.ps1') -Force
& $registerGE -EnforcerPath (Join-Path $runtimeDir 'GE-Enforce.ps1')
} catch {
Write-Warning "GE-Enforce registration failed: $_"
}
} else {
Write-Host "Register-GEEnforce.ps1 not found - skipping (legacy per-type enforcers remain active)"
}
# Legacy Common enforcer: kept for the transition period; GE-Enforce
# unregisters the task it creates. Remove this block when the legacy
# Common-Enforce.ps1 is retired from the repo.
$registerCommon = Join-Path $commonSetupDir 'Register-CommonEnforce.ps1' $registerCommon = Join-Path $commonSetupDir 'Register-CommonEnforce.ps1'
if (Test-Path -LiteralPath $registerCommon) { if (Test-Path -LiteralPath $registerCommon) {
Write-Host "" Write-Host ""
Write-Host "=== Registering Common Apps enforcer ===" Write-Host "=== (legacy) Registering Common Apps enforcer - will be superseded by GE-Enforce ==="
try { & $registerCommon } catch { Write-Warning "Common enforce registration failed: $_" } try { & $registerCommon } catch { Write-Warning "Common enforce registration failed: $_" }
} else {
Write-Host "Register-CommonEnforce.ps1 not found (optional) - skipping"
} }
# Map S: drive on user logon for every account in BUILTIN\Users. The # Map S: drive on user logon for every account in BUILTIN\Users. The

View File

@@ -14,14 +14,14 @@
"DetectionValue": "REPLACE_WITH_PINNED_UDC_VERSION" "DetectionValue": "REPLACE_WITH_PINNED_UDC_VERSION"
}, },
{ {
"_comment": "eDNC. Ships with NTLARS bundled (NTLARS.exe lands at C:\\Program Files (x86)\\Dnc\\Common\\ as part of the same install), so no separate NTLARS entry is needed. SITESELECTED encodes the site (was a recurring bug in early shopfloor-setup scripts that omitted it). Adjust to your site's value if not West Jefferson. Detection uses FileVersion on DncMain.exe so version upgrades actually fire (File-existence detection on NTLARS would skip the upgrade because the file already exists from the prior version). Update workflow: drop the new MSI on the SFLD share, bump DetectionValue + Installer in this manifest to the new vendor-stamped FileVersion, and the next user logon installs it.", "_comment": "eDNC 6.4.5. Ships with NTLARS bundled (NTLARS.exe lands at C:\\Program Files (x86)\\Dnc\\Common\\ as part of the same install), so no separate NTLARS entry is needed. SITESELECTED encodes the site (was a recurring bug in early shopfloor-setup scripts that omitted it). Adjust to your site's value if not West Jefferson. Detection uses FileVersion on DncMain.exe so version upgrades actually fire. The vendor stamps DncMain.exe with a 4-part version (e.g. '6.4.5.0'), not 3-part, so DetectionValue must be the exact 4-part string - an earlier 3-part value in this entry caused detection to always fail and the MSI reinstalled silently on every logon. Update workflow: drop the new MSI on the SFLD share, bump DetectionValue + Installer in this manifest to the new vendor-stamped FileVersion, and the next user logon installs it.",
"Name": "eDNC (bundles NTLARS)", "Name": "eDNC (bundles NTLARS)",
"Installer": "eDNC-6.4.3.msi", "Installer": "eDNC_6-4-5.msi",
"Type": "MSI", "Type": "MSI",
"InstallArgs": "/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress SITESELECTED=\"West Jefferson\"", "InstallArgs": "/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress SITESELECTED=\"West Jefferson\"",
"DetectionMethod": "FileVersion", "DetectionMethod": "FileVersion",
"DetectionPath": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe", "DetectionPath": "C:\\Program Files (x86)\\Dnc\\bin\\DncMain.exe",
"DetectionValue": "6.4.3" "DetectionValue": "6.4.5.0"
}, },
{ {
"_comment": "Custom eMxInfo.txt (site-specific eDNC config). No vendor installer - the secret file lives on the SFLD share alongside the eDNC MSI. Install-eMxInfo.cmd copies it to both 32-bit and 64-bit eDNC Program Files paths. Hash detection catches both 'file missing' and 'file is a stale version'. Yearly rotation procedure: drop the new eMxInfo.txt on the share, recompute its SHA256 (PowerShell: (Get-FileHash .\\eMxInfo.txt -Algorithm SHA256).Hash), paste the new hash into DetectionValue here, save. Every Machine PC catches up on the next user logon. Content-sensitive: eMxInfo.txt must NEVER be committed to git (already in .gitignore).", "_comment": "Custom eMxInfo.txt (site-specific eDNC config). No vendor installer - the secret file lives on the SFLD share alongside the eDNC MSI. Install-eMxInfo.cmd copies it to both 32-bit and 64-bit eDNC Program Files paths. Hash detection catches both 'file missing' and 'file is a stale version'. Yearly rotation procedure: drop the new eMxInfo.txt on the share, recompute its SHA256 (PowerShell: (Get-FileHash .\\eMxInfo.txt -Algorithm SHA256).Hash), paste the new hash into DetectionValue here, save. Every Machine PC catches up on the next user logon. Content-sensitive: eMxInfo.txt must NEVER be committed to git (already in .gitignore).",

View File

@@ -0,0 +1,97 @@
# Deploy-GEEnforce.ps1
#
# One-shot deploy of the GE-Enforce runtime + scheduled task to an already-
# imaged shopfloor PC. Use this to promote a PC from legacy per-type
# enforcers (CMM-Enforce, Keyence-Enforce, Machine-Enforce, etc.) to the
# unified GE-Enforce, without re-imaging.
#
# Usage (on target PC, as admin):
# powershell -ExecutionPolicy Bypass -File .\Deploy-GEEnforce.ps1 `
# -SourceRoot '\\<host>\<share>\enforcer-stage'
#
# The SourceRoot must contain:
# GE-Enforce.ps1
# lib\Install-FromManifest.ps1
#
# For imaging-time deployment, Run-ShopfloorSetup.ps1 invokes
# Register-GEEnforce.ps1 directly. This script is specifically for
# retrofitting live PCs.
param(
[Parameter(Mandatory=$true)]
[string]$SourceRoot,
[string]$InstallRoot = 'C:\Program Files\GE\Shopfloor',
[switch]$TriggerImmediate
)
$ErrorActionPreference = 'Stop'
function Write-DeployLog { param([string]$m) Write-Host "[Deploy-GEEnforce] $m" }
if (-not ([Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()).IsInRole('Administrators')) {
throw 'Must run as Administrator.'
}
Write-DeployLog "SourceRoot: $SourceRoot"
Write-DeployLog "InstallRoot: $InstallRoot"
# ---- 1. Validate source ----
$srcEnforcer = Join-Path $SourceRoot 'GE-Enforce.ps1'
$srcLib = Join-Path $SourceRoot 'lib\Install-FromManifest.ps1'
foreach ($p in @($srcEnforcer, $srcLib)) {
if (-not (Test-Path -LiteralPath $p)) {
throw "Missing source file: $p"
}
}
# ---- 2. Stage runtime ----
Write-DeployLog "Staging runtime to $InstallRoot"
$libDst = Join-Path $InstallRoot 'lib'
foreach ($d in @($InstallRoot, $libDst)) {
if (-not (Test-Path $d)) { New-Item -Path $d -ItemType Directory -Force | Out-Null }
}
Copy-Item -LiteralPath $srcEnforcer -Destination (Join-Path $InstallRoot 'GE-Enforce.ps1') -Force
Copy-Item -LiteralPath $srcLib -Destination (Join-Path $libDst 'Install-FromManifest.ps1') -Force
Write-DeployLog ' Runtime files copied'
# ---- 3. Register scheduled task + unregister legacy ----
$registerScript = Join-Path $SourceRoot 'Register-GEEnforce.ps1'
if (-not (Test-Path -LiteralPath $registerScript)) {
# Fall back to the copy that should sit next to this deploy script in the
# repo. In the common rollout, Register-GEEnforce.ps1 lives beside
# GE-Enforce.ps1 in $SourceRoot.
$registerScript = Join-Path (Split-Path -Parent $PSCommandPath) 'Register-GEEnforce.ps1'
}
if (-not (Test-Path -LiteralPath $registerScript)) {
throw "Cannot find Register-GEEnforce.ps1 in $SourceRoot or alongside this script."
}
Write-DeployLog "Invoking $registerScript"
& $registerScript -EnforcerPath (Join-Path $InstallRoot 'GE-Enforce.ps1')
# ---- 4. Optional: trigger one enforce cycle immediately ----
if ($TriggerImmediate) {
Write-DeployLog 'Triggering GE Shopfloor Enforce now via schtasks /run'
& schtasks /run /tn 'GE Shopfloor Enforce' | Out-Null
Start-Sleep -Seconds 3
$log = Get-ChildItem -Path 'C:\Logs\Shopfloor' -Filter 'enforce-*.log' -ErrorAction SilentlyContinue |
Sort-Object LastWriteTime -Descending | Select-Object -First 1
if ($log) {
Write-DeployLog "Latest log: $($log.FullName) (watching for 30s)"
$end = (Get-Date).AddSeconds(30)
$lastSize = 0
while ((Get-Date) -lt $end) {
$size = (Get-Item $log.FullName).Length
if ($size -gt $lastSize) {
Get-Content -Path $log.FullName -Tail 20 -ErrorAction SilentlyContinue | ForEach-Object { Write-Host " $_" }
Write-Host ' ---'
$lastSize = $size
}
Start-Sleep -Seconds 2
}
}
}
Write-DeployLog 'DONE. Verify with: schtasks /query /tn "GE Shopfloor Enforce" /v /fo list'

View File

@@ -0,0 +1,247 @@
# 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

View File

@@ -0,0 +1,101 @@
# Register-GEEnforce.ps1 - Registers "GE Shopfloor Enforce" scheduled task.
#
# Idempotent: deletes an existing task with the same name first. Also
# unregisters the legacy per-type enforcer tasks so they don't race.
#
# Triggers (all run as SYSTEM, RunLevel=Highest):
# - AtLogOn for first-boot catch-up
# - Repetition every 5 min for fleet auto-update (jittered start offset
# in the first 5 min window to spread 200 PCs)
# - Shift-change windows 05:45, 13:45, 21:45 EST (30-min window each;
# handled by running the task at window start
# and letting the lib run anything not yet
# applied - Stage 2b will gate ApplyMode=Nightly
# entries to only fire inside a window).
#
# Called from Run-ShopfloorSetup.ps1 at imaging time. Also usable manually
# to re-register on an existing PC.
param(
[string]$EnforcerPath = 'C:\Program Files\GE\Shopfloor\GE-Enforce.ps1'
)
$ErrorActionPreference = 'Stop'
$taskName = 'GE Shopfloor Enforce'
$legacyTasks = @(
'GE Common Enforce',
'GE Acrobat Enforce',
'GE Shopfloor Machine Apps Enforce',
'GE Machine Enforce',
'GE CMM Enforce',
'GE Keyence Enforce'
)
function Write-RegisterLog { param([string]$m) Write-Host "[Register-GEEnforce] $m" }
# Unregister legacy per-type enforcers so only GE-Enforce drives the fleet.
foreach ($name in $legacyTasks) {
$t = Get-ScheduledTask -TaskName $name -ErrorAction SilentlyContinue
if ($t) {
Write-RegisterLog "Unregistering legacy task: $name"
Unregister-ScheduledTask -TaskName $name -Confirm:$false -ErrorAction SilentlyContinue
}
}
# Drop an existing copy of our own task (re-imaging idempotency).
$existing = Get-ScheduledTask -TaskName $taskName -ErrorAction SilentlyContinue
if ($existing) {
Write-RegisterLog "Removing existing scheduled task: $taskName"
Unregister-ScheduledTask -TaskName $taskName -Confirm:$false -ErrorAction SilentlyContinue
}
# --- Action ---
$action = New-ScheduledTaskAction `
-Execute 'powershell.exe' `
-Argument "-NoProfile -ExecutionPolicy Bypass -File `"$EnforcerPath`""
# --- Triggers ---
# Per-PC random offset [0, 5) min so 200 PCs don't all fire on :00/:05/:10/...
# Derived from hostname hash so the same PC always picks the same offset.
$hostHash = [System.BitConverter]::ToUInt32(
[System.Security.Cryptography.MD5]::Create().ComputeHash(
[System.Text.Encoding]::UTF8.GetBytes($env:COMPUTERNAME)), 0)
$offsetMin = $hostHash % 5 # 0..4
$startToday = (Get-Date -Hour 0 -Minute $offsetMin -Second 0).AddSeconds(0)
$logonTrigger = New-ScheduledTaskTrigger -AtLogOn
# Periodic every 5 minutes, repeating indefinitely, starting at the offset
$periodicTrigger = New-ScheduledTaskTrigger -Once -At $startToday -RepetitionInterval (New-TimeSpan -Minutes 5)
$shift1Trigger = New-ScheduledTaskTrigger -Daily -At '05:45' # 3-to-1 shift
$shift2Trigger = New-ScheduledTaskTrigger -Daily -At '13:45' # 1-to-2
$shift3Trigger = New-ScheduledTaskTrigger -Daily -At '21:45' # 2-to-3
$triggers = @($logonTrigger, $periodicTrigger, $shift1Trigger, $shift2Trigger, $shift3Trigger)
# --- Principal + Settings ---
$principal = New-ScheduledTaskPrincipal -UserId 'SYSTEM' -LogonType ServiceAccount -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet `
-AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries `
-StartWhenAvailable `
-ExecutionTimeLimit (New-TimeSpan -Hours 1) `
-MultipleInstances IgnoreNew
$description = "GE Shopfloor unified enforcer. Reads common + pc-type manifests from " +
"\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\, runs " +
"Install-FromManifest.ps1 against each. Periodic 5-min jitter + " +
"AtLogOn + 3x shift-change windows. Log: C:\Logs\Shopfloor\enforce-*.log"
Register-ScheduledTask `
-TaskName $taskName `
-Action $action `
-Trigger $triggers `
-Principal $principal `
-Settings $settings `
-Description $description | Out-Null
Write-RegisterLog "Registered '$taskName' (offset $offsetMin min)"
Write-RegisterLog " Triggers: AtLogOn, every 5min, 05:45, 13:45, 21:45"
Write-RegisterLog " Action: powershell -File $EnforcerPath"

View File

@@ -1,30 +1,49 @@
# Install-FromManifest.ps1 - Generic JSON-manifest installer for cross-PC-type # Install-FromManifest.ps1 - Generic JSON-manifest runner for shopfloor apps.
# apps enforced from the SFLD share (Acrobat Reader DC today; others later).
# #
# Duplicated from CMM\lib\Install-FromManifest.ps1 with a few differences: # Stage 2a extension: adds PS1, BAT, File, Registry, INF action types and
# - adds Type=CMD (cmd.exe /c wrapper, needed for Acrobat's two-step # Always / MarkerFile / ValueMatches / pnputil detection methods. Also adds
# MSI + MSP install that the vendor ships as Install-AcroReader.cmd) # optional PCTypes filtering (entry skipped if current pc-type.txt doesn't
# - unchanged otherwise; a future pass will unify both libraries. # match any value in the entry's PCTypes array).
# #
# Called from: # Manifest-schema fields parsed-but-not-yet-acted-on (Stage 2b will wire them):
# - Acrobat-Enforce.ps1 on logon with InstallerRoot=<mounted tsgwp00525 share> # ApplyMode, UpdateWindow, InUseCheck. The lib treats all entries as
# immediate-apply today; Stage 2b adds shift-window gating and
# close-and-reopen behavior.
# #
# Returns via exit code: 0 if every required app is either already installed # Consumers:
# or installed successfully; non-zero if any install failed. # - GE-Enforce.ps1 (new unified dispatcher, runs via scheduled task with
# both logon and periodic triggers)
# - Legacy *-Enforce.ps1 that still point here (will be retired)
#
# Exit codes:
# 0 = every required entry either already satisfied or installed successfully
# 1 = at least one entry failed
# 2 = manifest or installer-root could not be read
param( param(
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$true)] [string]$ManifestPath,
[string]$ManifestPath, [Parameter(Mandatory=$true)] [string]$InstallerRoot,
[Parameter(Mandatory=$true)] [string]$LogFile,
[Parameter(Mandatory=$true)] [Parameter(Mandatory=$false)] [string]$PCType,
[string]$InstallerRoot, [Parameter(Mandatory=$false)] [string]$PCSubType
[Parameter(Mandatory=$true)]
[string]$LogFile
) )
$ErrorActionPreference = 'Continue' $ErrorActionPreference = 'Continue'
# Lib's supported manifest schema version. Bump the MAJOR part whenever a
# manifest field or behavior changes in a way older libs can't handle.
# Bump the MINOR part for additive, backward-compatible additions (new
# optional field, new Type, new DetectionMethod). Manifests tagged with
# a newer MAJOR than the lib get processed best-effort with unknown Types
# logged; manifests tagged with a newer MINOR are fine.
#
# Changelog:
# 2.1 - added TargetHostnames filter (exact + -like wildcards)
# 2.0 - initial Stage 2a: PS1/BAT/File/Registry/INF action types,
# Always/MarkerFile/ValueMatches/pnputil detection, PCTypes filter
$LIB_MANIFEST_MAJOR = 2
$LIB_MANIFEST_MINOR = 1
$logDir = Split-Path -Parent $LogFile $logDir = Split-Path -Parent $LogFile
if (-not (Test-Path $logDir)) { if (-not (Test-Path $logDir)) {
New-Item -Path $logDir -ItemType Directory -Force | Out-Null New-Item -Path $logDir -ItemType Directory -Force | Out-Null
@@ -32,13 +51,12 @@ if (-not (Test-Path $logDir)) {
function Write-InstallLog { function Write-InstallLog {
param( param(
[Parameter(Mandatory=$true, Position=0)] [Parameter(Mandatory=$true, Position=0)] [string]$Message,
[string]$Message,
[Parameter(Position=1)] [Parameter(Position=1)]
[ValidateSet('INFO','WARN','ERROR')] [ValidateSet('INFO','WARN','ERROR')]
[string]$Level = 'INFO' [string]$Level = 'INFO'
) )
$stamp = Get-Date -Format "yyyy-MM-dd HH:mm:ss" $stamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
$line = "[$stamp] [$Level] $Message" $line = "[$stamp] [$Level] $Message"
Write-Host $line Write-Host $line
try { try {
@@ -59,36 +77,60 @@ function Write-InstallLog {
} }
} }
Write-InstallLog "================================================================" Write-InstallLog '================================================================'
Write-InstallLog "=== Install-FromManifest session start (PID $PID) ===" Write-InstallLog "=== Install-FromManifest session start (PID $PID) ==="
Write-InstallLog "Manifest: $ManifestPath" Write-InstallLog "Manifest: $ManifestPath"
Write-InstallLog "InstallerRoot: $InstallerRoot" Write-InstallLog "InstallerRoot: $InstallerRoot"
Write-InstallLog "================================================================" if ($PCType) { Write-InstallLog "PCType: $PCType" }
if ($PCSubType) { Write-InstallLog "PCSubType: $PCSubType" }
Write-InstallLog '================================================================'
if (-not (Test-Path -LiteralPath $ManifestPath)) { if (-not (Test-Path -LiteralPath $ManifestPath)) {
Write-InstallLog "Manifest not found: $ManifestPath" "ERROR" Write-InstallLog "Manifest not found: $ManifestPath" 'ERROR'
exit 2 exit 2
} }
if (-not (Test-Path -LiteralPath $InstallerRoot)) { if (-not (Test-Path -LiteralPath $InstallerRoot)) {
Write-InstallLog "InstallerRoot not found: $InstallerRoot" "ERROR" Write-InstallLog "InstallerRoot not found: $InstallerRoot" 'ERROR'
exit 2 exit 2
} }
try { try {
$config = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json $config = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
} catch { } catch {
Write-InstallLog "Failed to parse manifest: $_" "ERROR" Write-InstallLog "Failed to parse manifest: $_" 'ERROR'
exit 2 exit 2
} }
if (-not $config.Applications) { if (-not $config.Applications -or $config.Applications.Count -eq 0) {
Write-InstallLog "No Applications in manifest - nothing to do" Write-InstallLog 'No Applications in manifest - nothing to do'
exit 0 exit 0
} }
# ---------------------------------------------------------------------------
# Manifest schema version check. Accepts "X" or "X.Y" form. If the manifest
# declares a MAJOR higher than the lib supports, log a clear warning so the
# operator sees "upgrade the lib on this PC" instead of weird silent
# skip-everything behavior. The lib still tries to run (unknown Types will
# log as ERROR and count as failed, but supported entries work).
# ---------------------------------------------------------------------------
$manifestMajor = 0
$manifestMinor = 0
if ($config.Version) {
$parts = "$($config.Version)".Split('.')
if ($parts.Length -ge 1 -and [int]::TryParse($parts[0], [ref]$null)) { $manifestMajor = [int]$parts[0] }
if ($parts.Length -ge 2 -and [int]::TryParse($parts[1], [ref]$null)) { $manifestMinor = [int]$parts[1] }
}
if ($manifestMajor -gt $LIB_MANIFEST_MAJOR) {
Write-InstallLog " Manifest schema version $($config.Version) is newer than this lib (supports $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR). Unknown Types/fields will be logged as errors. Upgrade the lib on this PC." 'WARN'
} elseif ($manifestMajor -eq $LIB_MANIFEST_MAJOR -and $manifestMinor -gt $LIB_MANIFEST_MINOR) {
Write-InstallLog " Manifest schema $($config.Version) is minor-newer than lib $LIB_MANIFEST_MAJOR.$LIB_MANIFEST_MINOR. Should be backward compatible; any unknown Types will be logged." 'INFO'
}
Write-InstallLog "Manifest lists $($config.Applications.Count) app(s)" Write-InstallLog "Manifest lists $($config.Applications.Count) app(s)"
# ---------------------------------------------------------------------------
# Detection
# ---------------------------------------------------------------------------
function Test-AppInstalled { function Test-AppInstalled {
param($App) param($App)
@@ -96,7 +138,7 @@ function Test-AppInstalled {
try { try {
switch ($App.DetectionMethod) { switch ($App.DetectionMethod) {
"Registry" { 'Registry' {
if (-not (Test-Path $App.DetectionPath)) { return $false } if (-not (Test-Path $App.DetectionPath)) { return $false }
if ($App.DetectionName) { if ($App.DetectionName) {
$value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue $value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
@@ -108,182 +150,333 @@ function Test-AppInstalled {
} }
return $true return $true
} }
"File" { 'File' {
return Test-Path $App.DetectionPath return Test-Path $App.DetectionPath
} }
"FileVersion" { 'FileVersion' {
# Compare a file's VersionInfo.FileVersion against the
# manifest's expected value. Used for version-pinned MSI/EXE
# installs where existence alone doesn't tell you whether
# the right release is on disk (e.g. eDNC 6.4.3 vs 6.4.4
# both leave NTLARS.exe in the same path). Exact string
# match - the manifest must carry the exact version the
# vendor stamps into the binary.
if (-not (Test-Path $App.DetectionPath)) { return $false } if (-not (Test-Path $App.DetectionPath)) { return $false }
if (-not $App.DetectionValue) { if (-not $App.DetectionValue) {
Write-InstallLog " FileVersion detection requires DetectionValue - treating as not installed" "WARN" Write-InstallLog ' FileVersion detection requires DetectionValue - treating as not installed' 'WARN'
return $false return $false
} }
$actual = (Get-Item $App.DetectionPath -ErrorAction Stop).VersionInfo.FileVersion $actual = (Get-Item $App.DetectionPath -ErrorAction Stop).VersionInfo.FileVersion
if (-not $actual) { return $false } if (-not $actual) { return $false }
return ($actual -eq $App.DetectionValue) return ($actual -eq $App.DetectionValue)
} }
"Hash" { 'Hash' {
# Compare SHA256 of the on-disk file against the manifest's
# expected value. Used for content-versioned files that do not
# expose a DisplayVersion (secrets like eMxInfo.txt). Bumping
# DetectionValue in the manifest and replacing the file on the
# share is the entire update workflow.
if (-not (Test-Path $App.DetectionPath)) { return $false } if (-not (Test-Path $App.DetectionPath)) { return $false }
if (-not $App.DetectionValue) { if (-not $App.DetectionValue) {
Write-InstallLog " Hash detection requires DetectionValue - treating as not installed" "WARN" Write-InstallLog ' Hash detection requires DetectionValue - treating as not installed' 'WARN'
return $false return $false
} }
$actual = (Get-FileHash -Path $App.DetectionPath -Algorithm SHA256 -ErrorAction Stop).Hash $actual = (Get-FileHash -Path $App.DetectionPath -Algorithm SHA256 -ErrorAction Stop).Hash
return ($actual -ieq $App.DetectionValue) return ($actual -ieq $App.DetectionValue)
} }
'MarkerFile' {
# Used for one-shot PS1 scripts. Presence of the marker file
# means the script already ran successfully. The lib writes
# the marker automatically after a 0 exit on PS1/BAT/CMD.
if (-not $App.DetectionPath) { return $false }
return Test-Path $App.DetectionPath
}
'ValueMatches' {
# For Type=Registry entries. Check that the registry value
# equals the desired RegValue (read from the app entry).
if (-not (Test-Path $App.DetectionPath)) { return $false }
if (-not $App.DetectionName) {
Write-InstallLog ' ValueMatches detection requires DetectionName' 'WARN'
return $false
}
$p = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
if (-not $p) { return $false }
return ("$($p.$($App.DetectionName))" -eq "$($App.RegValue)")
}
'pnputil' {
# For INF drivers. Run pnputil /enum-drivers once and grep.
if (-not $App.DetectionPattern) {
Write-InstallLog ' pnputil detection requires DetectionPattern' 'WARN'
return $false
}
$drivers = & pnputil /enum-drivers 2>&1 | Out-String
return ($drivers -match $App.DetectionPattern)
}
'Always' {
# Never considered installed - entry runs every cycle.
# Useful for idempotent scripts like the VNC firewall rule.
return $false
}
default { default {
Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" "WARN" Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" 'WARN'
return $false return $false
} }
} }
} catch { } catch {
Write-InstallLog " Detection check threw: $_" "WARN" Write-InstallLog " Detection check threw: $_" 'WARN'
return $false return $false
} }
} }
$installed = 0 # ---------------------------------------------------------------------------
$skipped = 0 # Action dispatch
$failed = 0 # ---------------------------------------------------------------------------
function Invoke-InstallerAction {
param($App)
foreach ($app in $config.Applications) {
cmd /c "shutdown /a 2>nul" *>$null
Write-InstallLog "==> $($app.Name)"
if (Test-AppInstalled -App $app) {
Write-InstallLog " Already installed at expected version - skipping"
$skipped++
continue
}
$installerPath = Join-Path $InstallerRoot $app.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " Installer file not found: $installerPath" "ERROR"
$failed++
continue
}
Write-InstallLog " Installing from $installerPath"
if ($app.InstallArgs) {
Write-InstallLog " InstallArgs: $($app.InstallArgs)"
}
try {
$psi = New-Object System.Diagnostics.ProcessStartInfo $psi = New-Object System.Diagnostics.ProcessStartInfo
$psi.UseShellExecute = $false $psi.UseShellExecute = $false
$psi.CreateNoWindow = $true $psi.CreateNoWindow = $true
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden $psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
$msiLog = $null switch ($App.Type) {
'MSI' {
if ($app.Type -eq "MSI") { $installerPath = Join-Path $InstallerRoot $App.Installer
$safeName = $app.Name -replace '[^a-zA-Z0-9]','_' if (-not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " MSI not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$safeName = $App.Name -replace '[^a-zA-Z0-9]','_'
$msiLog = Join-Path $logDir "msi-$safeName.log" $msiLog = Join-Path $logDir "msi-$safeName.log"
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue } if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
$psi.FileName = 'msiexec.exe'
$psi.FileName = "msiexec.exe"
$psi.Arguments = "/i `"$installerPath`"" $psi.Arguments = "/i `"$installerPath`""
if ($app.InstallArgs) { $psi.Arguments += " " + $app.InstallArgs } if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs }
$psi.Arguments += " /L*v `"$msiLog`"" $psi.Arguments += " /L*v `"$msiLog`""
Write-InstallLog " msiexec verbose log: $msiLog" Write-InstallLog " msiexec: $installerPath"
Write-InstallLog " verbose log: $msiLog"
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $msiLog }
}
'EXE' {
$installerPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " EXE not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
} }
elseif ($app.Type -eq "EXE") {
$psi.FileName = $installerPath $psi.FileName = $installerPath
if ($app.InstallArgs) { $psi.Arguments = $app.InstallArgs } if ($App.InstallArgs) { $psi.Arguments = $App.InstallArgs }
if ($app.LogFile) { Write-InstallLog " exe: $installerPath"
Write-InstallLog " Installer log: $($app.LogFile)" if ($App.InstallArgs) { Write-InstallLog " args: $($App.InstallArgs)" }
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile }
} }
{ $_ -eq 'CMD' -or $_ -eq 'BAT' } {
$installerPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $installerPath)) {
Write-InstallLog " CMD/BAT not found: $installerPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
} }
elseif ($app.Type -eq "CMD") { $psi.FileName = 'cmd.exe'
# .cmd/.bat scripts cannot be executed directly via
# ProcessStartInfo with UseShellExecute=false; route through
# cmd.exe /c. Vendor-provided two-step install wrappers
# (Install-AcroReader.cmd) fit here naturally.
$psi.FileName = "cmd.exe"
$psi.Arguments = "/c `"$installerPath`"" $psi.Arguments = "/c `"$installerPath`""
if ($app.InstallArgs) { $psi.Arguments += " " + $app.InstallArgs } if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs }
if ($app.LogFile) { Write-InstallLog " cmd /c $installerPath"
Write-InstallLog " Installer log: $($app.LogFile)" $proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $App.LogFile }
}
'PS1' {
$scriptPath = Join-Path $InstallerRoot ($App.Script)
if (-not (Test-Path -LiteralPath $scriptPath)) {
Write-InstallLog " PS1 not found: $scriptPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$psi.FileName = 'powershell.exe'
$psi.Arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$scriptPath`""
if ($App.Args) { $psi.Arguments += " " + $App.Args }
Write-InstallLog " ps1: $scriptPath"
if ($App.Args) { Write-InstallLog " args: $($App.Args)" }
$proc = [System.Diagnostics.Process]::Start($psi)
$proc.WaitForExit()
return [pscustomobject]@{ ExitCode = $proc.ExitCode; LogRef = $null }
}
'INF' {
$infPath = Join-Path $InstallerRoot $App.Installer
if (-not (Test-Path -LiteralPath $infPath)) {
Write-InstallLog " INF not found: $infPath" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
Write-InstallLog " pnputil /add-driver `"$infPath`" /install"
$out = & pnputil /add-driver "$infPath" /install 2>&1 | Out-String
Write-InstallLog " pnputil output: $($out.Trim())"
# pnputil exits 0 on success, 259 if already installed, etc.
return [pscustomobject]@{ ExitCode = $LASTEXITCODE; LogRef = $null }
}
'File' {
# Copy a file from the share (configs/*) to an absolute on-PC path.
$source = Join-Path $InstallerRoot $App.Source
if (-not (Test-Path -LiteralPath $source)) {
Write-InstallLog " File source not found: $source" 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
if (-not $App.Destination) {
Write-InstallLog ' File entry missing Destination' 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
$destDir = Split-Path -Parent $App.Destination
if ($destDir -and -not (Test-Path $destDir)) {
New-Item -Path $destDir -ItemType Directory -Force | Out-Null
}
try {
Copy-Item -LiteralPath $source -Destination $App.Destination -Force -ErrorAction Stop
Write-InstallLog " copied $source -> $($App.Destination)"
return [pscustomobject]@{ ExitCode = 0; LogRef = $null }
} catch {
Write-InstallLog " Copy failed: $_" 'ERROR'
return [pscustomobject]@{ ExitCode = 1; LogRef = $null }
} }
} }
else { 'Registry' {
Write-InstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR" # Write a single registry value.
$failed++ if (-not $App.RegPath -or -not $App.RegName) {
Write-InstallLog ' Registry entry missing RegPath/RegName' 'ERROR'
return [pscustomobject]@{ ExitCode = -1; LogRef = $null }
}
if (-not (Test-Path $App.RegPath)) {
New-Item -Path $App.RegPath -Force | Out-Null
}
$type = if ($App.RegType) { $App.RegType } else { 'String' }
try {
Set-ItemProperty -Path $App.RegPath -Name $App.RegName -Value $App.RegValue -Type $type -Force -ErrorAction Stop
Write-InstallLog " set $($App.RegPath)\$($App.RegName) = $($App.RegValue) ($type)"
return [pscustomobject]@{ ExitCode = 0; LogRef = $null }
} catch {
Write-InstallLog " Registry write failed: $_" 'ERROR'
return [pscustomobject]@{ ExitCode = 1; LogRef = $null }
}
}
default {
Write-InstallLog " Unsupported Type: $($App.Type)" 'ERROR'
return [pscustomobject]@{ ExitCode = -2; LogRef = $null }
}
}
}
# ---------------------------------------------------------------------------
# Entry-applies filter. An entry applies to this PC only if:
# - PCTypes is omitted OR matches current pcType/pcType-subType/"*", AND
# - TargetHostnames is omitted OR matches current COMPUTERNAME
# (supports exact match and -like wildcards: "WJS-*", "*-SHOP-*").
# Both filters are ANDed so they compose: scope to a type AND a hostname
# subset, or either alone. Case-insensitive throughout.
# ---------------------------------------------------------------------------
function Test-PCTypeMatches {
param($App, [string]$Type, [string]$SubType)
if (-not $App.PCTypes -or $App.PCTypes.Count -eq 0) { return $true }
if (-not $Type) { return $true }
foreach ($t in $App.PCTypes) {
if ($t -eq '*') { return $true }
if ($t -eq $Type) { return $true }
if ($SubType -and $t -eq "$Type-$SubType") { return $true }
}
return $false
}
function Test-HostnameMatches {
param($App)
if (-not $App.TargetHostnames -or $App.TargetHostnames.Count -eq 0) { return $true }
$myName = $env:COMPUTERNAME
foreach ($h in $App.TargetHostnames) {
if ($h -ieq $myName) { return $true }
if ($myName -ilike $h) { return $true } # glob patterns: WJS-*, *-SHOP-*
}
return $false
}
# ---------------------------------------------------------------------------
# Main loop
# ---------------------------------------------------------------------------
$installed = 0
$skipped = 0
$failed = 0
$pcFiltered = 0
foreach ($app in $config.Applications) {
# Cancel any reboot that a prior MSI queued, so the enforcer never
# triggers an unexpected restart on a shopfloor PC.
cmd /c 'shutdown /a 2>nul' *>$null
Write-InstallLog "==> $($app.Name)"
if (-not (Test-PCTypeMatches -App $app -Type $PCType -SubType $PCSubType)) {
Write-InstallLog " PCTypes filter: entry targets $($app.PCTypes -join ',') but PC is $PCType$(if ($PCSubType) { "-$PCSubType" }) - skipping"
$pcFiltered++
continue continue
} }
$proc = [System.Diagnostics.Process]::Start($psi) if (-not (Test-HostnameMatches -App $app)) {
$proc.WaitForExit() Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $env:COMPUTERNAME - skipping"
$exitCode = $proc.ExitCode $pcFiltered++
continue
}
if ($exitCode -eq 0 -or $exitCode -eq 1641 -or $exitCode -eq 3010) { if (Test-AppInstalled -App $app) {
Write-InstallLog " Exit code $exitCode - SUCCESS" Write-InstallLog ' Already installed at expected version - skipping'
if ($exitCode -eq 3010) { Write-InstallLog " (Reboot pending for $($app.Name))" } $skipped++
if ($exitCode -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" } continue
}
$result = Invoke-InstallerAction -App $app
$rc = $result.ExitCode
if ($rc -eq 0 -or $rc -eq 1641 -or $rc -eq 3010 -or $rc -eq 259) {
Write-InstallLog " Exit $rc - SUCCESS"
if ($rc -eq 3010) { Write-InstallLog " (Reboot pending for $($app.Name))" }
if ($rc -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" }
if ($rc -eq 259) { Write-InstallLog ' (pnputil: no newer driver found - considered installed)' }
$installed++ $installed++
# Auto-write marker file for MarkerFile-detected entries that just
# completed successfully. Keeps one-shot PS1 scripts from running
# twice (idempotent scripts can skip this by using Always detection).
if ($app.DetectionMethod -eq 'MarkerFile' -and $app.DetectionPath) {
$markerDir = Split-Path -Parent $app.DetectionPath
if ($markerDir -and -not (Test-Path $markerDir)) {
New-Item -Path $markerDir -ItemType Directory -Force | Out-Null
}
try {
Set-Content -Path $app.DetectionPath -Value (Get-Date -Format 'o') -ErrorAction Stop
Write-InstallLog " marker written: $($app.DetectionPath)"
} catch {
Write-InstallLog " marker write failed: $_" 'WARN'
}
}
} }
else { else {
Write-InstallLog " Exit code $exitCode - FAILED" "ERROR" Write-InstallLog " Exit $rc - FAILED" 'ERROR'
if (($app.Type -eq "EXE" -or $app.Type -eq "CMD") -and $app.LogFile -and (Test-Path $app.LogFile)) { if ($result.LogRef -and (Test-Path $result.LogRef)) {
Write-InstallLog " --- last 30 lines of $($app.LogFile) ---" if ($app.Type -eq 'MSI') {
Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " --- meaningful lines from $($result.LogRef) ---"
Write-InstallLog " $_"
}
Write-InstallLog " --- end installer log tail ---"
}
if ($app.Type -eq "MSI" -and $msiLog -and (Test-Path $msiLog)) {
Write-InstallLog " --- meaningful lines from $msiLog ---"
$patterns = @( $patterns = @(
'Note: 1: ', 'Note: 1: ', 'return value 3', 'Error \d+\.',
'return value 3', 'CustomAction .* returned actual error', 'Failed to ',
'Error \d+\.', 'Installation failed', '1: 2262', '1: 2203', '1: 2330'
'CustomAction .* returned actual error',
'Failed to ',
'Installation failed',
'1: 2262',
'1: 2203',
'1: 2330'
) )
$regex = ($patterns -join '|') $regex = ($patterns -join '|')
$matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue | $matches = Select-String -Path $result.LogRef -Pattern $regex -ErrorAction SilentlyContinue | Select-Object -First 30
Select-Object -First 30
if ($matches) { if ($matches) {
foreach ($m in $matches) { Write-InstallLog " $($m.Line.Trim())" } foreach ($m in $matches) { Write-InstallLog " $($m.Line.Trim())" }
} else { } else {
Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Get-Content $result.LogRef -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" }
Write-InstallLog " $_"
} }
Write-InstallLog ' --- end MSI log scan ---'
} else {
Write-InstallLog " --- last 30 lines of $($result.LogRef) ---"
Get-Content $result.LogRef -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" }
Write-InstallLog ' --- end installer log tail ---'
} }
Write-InstallLog " --- end MSI log scan ---"
} }
$failed++
}
} catch {
Write-InstallLog " Install threw: $_" "ERROR"
$failed++ $failed++
} }
} }
Write-InstallLog "============================================" Write-InstallLog '============================================'
Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed" Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed, $pcFiltered pc-filtered"
Write-InstallLog "============================================" Write-InstallLog '============================================'
cmd /c "shutdown /a 2>nul" *>$null cmd /c 'shutdown /a 2>nul' *>$null
if ($failed -gt 0) { exit 1 } if ($failed -gt 0) { exit 1 }
exit 0 exit 0

View File

@@ -0,0 +1,251 @@
#!/usr/bin/env python3
"""monitor-fleet-status.py
Reads status.json writebacks from the shopfloor enforcer output tree and
flags:
- PCs that haven't checked in within a stale-threshold window
- PCs with any failed scope from their last run
- Expected-vs-installed version mismatches (drift) when --manifests is
supplied
Designed to run as a cron job on the PXE server (or any box with read
access to the share). Prints plaintext report to stdout; non-zero exit
code when anything needs attention so it's trivial to wrap in an alerting
script.
Usage:
./monitor-fleet-status.py --status-root /path/to/_outputs/logs
./monitor-fleet-status.py --status-root /.../_outputs/logs --stale-hours 24
./monitor-fleet-status.py --status-root /.../_outputs/logs \\
--manifests /.../common/manifest.json /.../cmm/manifest.json
Typical cron (runs hourly, mails the root user on any output):
0 * * * * camp /home/camp/bin/monitor-fleet-status.py \\
--status-root /home/camp/pxe-images/tsgwp00525-v2/shared/dt/shopfloor/_outputs/logs \\
--stale-hours 24 2>&1 | tail -100
"""
from __future__ import annotations
import argparse
import datetime as dt
import fnmatch
import json
import pathlib
import sys
from typing import Any, Iterable
def load_json(path: pathlib.Path) -> dict[str, Any] | None:
try:
return json.loads(path.read_text())
except Exception as e:
print(f"[!] {path}: parse failed: {e}", file=sys.stderr)
return None
def age_hours(iso_utc: str) -> float | None:
try:
t = dt.datetime.fromisoformat(iso_utc.replace('Z', '+00:00'))
now = dt.datetime.now(dt.timezone.utc)
return (now - t).total_seconds() / 3600.0
except Exception:
return None
def load_manifest_expectations(paths: Iterable[pathlib.Path]) -> list[dict[str, Any]]:
"""Load manifest entries with enough metadata to know which PCs each
entry should apply to. Returns a list of dicts, one per entry that has
a DetectionValue:
{ key: "scope/Name", expected: "...", scope: "common|<type>|<type>-<sub>",
pctypes: [...], target_hostnames: [...] }
Scope comes from the manifest file's parent directory name and is
treated as an implicit PC-type filter (parallels the lib's per-scope
dispatch in GE-Enforce.ps1).
"""
out: list[dict[str, Any]] = []
for p in paths:
m = load_json(p)
if not m:
continue
scope = p.parent.name
for app in m.get('Applications', []):
name = app.get('Name')
val = app.get('DetectionValue')
if not (name and val):
continue
out.append({
'key': f"{scope}/{name}",
'expected': val,
'scope': scope,
'pctypes': app.get('PCTypes') or [],
'target_hostnames': app.get('TargetHostnames') or [],
})
return out
def scope_applies_to_host(scope: str, pc_type: str, pc_sub_type: str) -> bool:
"""Mirror GE-Enforce.ps1's per-scope dispatch:
common -> applied to every PC type
<type> -> only when pc-type.txt matches <type>
<type>-<subtype> -> only when pc-type matches AND subtype matches
Case-insensitive.
"""
s = scope.lower()
if s in ('common', ''):
return True
if '-' in s:
t, sub = s.split('-', 1)
return (t == pc_type and sub == pc_sub_type)
return s == pc_type
def entry_applies_to_host(entry: dict[str, Any],
pc_type: str | None,
pc_sub_type: str | None,
hostname: str) -> bool:
"""Mirror the lib's entry-applies filter: scope + PCTypes + TargetHostnames,
all ANDed. Drift checks only flag entries that should have actually been
applied on this PC.
"""
pc_type = (pc_type or '').lower()
pc_sub_type = (pc_sub_type or '').lower()
hostname_lc = hostname.lower()
# Scope filter: per-type manifests are implicitly scoped by the dir name.
if not scope_applies_to_host(entry.get('scope', ''), pc_type, pc_sub_type):
return False
# PCTypes filter (explicit; applies within a scope): if set, PC must match.
pctypes = entry.get('pctypes') or []
if pctypes:
if not pc_type:
return False
matched = False
for t in pctypes:
t_lc = t.lower()
if t_lc == '*': matched = True; break
if t_lc == pc_type: matched = True; break
if pc_sub_type and t_lc == f"{pc_type}-{pc_sub_type}":
matched = True; break
if not matched:
return False
# TargetHostnames filter: if set, hostname must match exact or glob.
target_hosts = entry.get('target_hostnames') or []
if target_hosts:
matched = False
for h in target_hosts:
h_lc = h.lower()
if h_lc == hostname_lc: matched = True; break
if fnmatch.fnmatch(hostname_lc, h_lc): matched = True; break
if not matched:
return False
return True
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument('--status-root', required=True,
help='Root path like <share>/_outputs/logs/')
ap.add_argument('--stale-hours', type=float, default=24.0,
help='Warn if a PC hasn\'t checked in in this many hours (default 24)')
ap.add_argument('--manifests', nargs='*', type=pathlib.Path, default=[],
help='Optional manifest paths; when set, drift between manifest '
'DetectionValue and PC-reported installedVersion is flagged.')
args = ap.parse_args()
root = pathlib.Path(args.status_root)
if not root.is_dir():
print(f"ERROR: status-root not found: {root}", file=sys.stderr)
return 2
expectations = load_manifest_expectations(args.manifests)
issues = 0
seen = 0
stale = []
failed = []
drift = []
for host_dir in sorted(p for p in root.iterdir() if p.is_dir()):
status_file = host_dir / 'status.json'
if not status_file.exists():
continue
st = load_json(status_file)
if not st:
continue
host = st.get('hostname') or host_dir.name
pc_type = st.get('pcType')
sub_type = st.get('pcSubType')
seen += 1
# --- stale ---
hrs = age_hours(st.get('lastCheckIn', ''))
if hrs is None:
stale.append((host, 'unparseable timestamp'))
issues += 1
elif hrs > args.stale_hours:
stale.append((host, f'{hrs:.1f}h since last check-in (> {args.stale_hours}h)'))
issues += 1
# --- per-scope failures ---
for scope in (st.get('scopesProcessed') or []):
if (scope.get('ExitCode') or 0) != 0:
failed.append((host, scope.get('Label'), scope.get('ExitCode')))
issues += 1
# --- version drift ---
# Only check entries that should have applied to this PC. Entries
# with PCTypes or TargetHostnames filters that exclude this host
# are legitimately not installed and must not be flagged as drift.
if expectations:
installed = st.get('installedVersions', {}) or {}
for entry in expectations:
if not entry_applies_to_host(entry, pc_type, sub_type, host):
continue
key = entry['key']
want = entry['expected']
got = installed.get(key)
if got is None:
drift.append((host, key, 'missing', want))
issues += 1
elif str(got).upper() != str(want).upper():
drift.append((host, key, got, want))
issues += 1
# --- report ---
print(f"Fleet status monitor - scanned {seen} host(s) under {root}")
print(f" stale threshold: {args.stale_hours}h")
if args.manifests:
print(f" drift against: {', '.join(str(p) for p in args.manifests)}")
print()
if not issues:
print('All checked-in hosts are healthy.')
return 0
if stale:
print(f"STALE CHECK-INS ({len(stale)}):")
for host, msg in stale:
print(f" {host}: {msg}")
print()
if failed:
print(f"SCOPE FAILURES ({len(failed)}):")
for host, label, rc in failed:
print(f" {host}: scope '{label}' exited {rc}")
print()
if drift:
print(f"VERSION DRIFT ({len(drift)}):")
for host, key, got, want in drift:
print(f" {host}: {key} got={got} want={want}")
print()
print(f"Total issues: {issues}")
return 1
if __name__ == '__main__':
sys.exit(main())

View File

@@ -0,0 +1,77 @@
# Shopfloor enforcer regression tests
Lightweight harness for end-to-end validation of `GE-Enforce.ps1` +
`Install-FromManifest.ps1` against the v2 staging tree, using the Win11
analyzer VM as a synthetic shopfloor PC.
## Files
- `vm-test-harness.ps1` — setup + invocation of GE-Enforce inside the VM.
Accepts `-PCType` and `-PCSubType` parameters. Creates
`C:\Enrollment\` stubs (pc-type.txt, pc-subtype.txt, site-config.json),
stages the enforcer runtime from `\\192.168.122.1\pxe-images\enforcer-stage\`,
injects a fake SFLD credential in `HKLM:\SOFTWARE\GE\SFLD\Credentials\samba`
pointing at the host's samba share as if it were tsgwp00525, then runs
`GE-Enforce.ps1` with output captured.
## Prereqs
- `win11` libvirt VM running, IP reachable at 192.168.122.210
- qemu-guest-agent exec path available (`/tmp/guest-exec.sh`)
- host samba shares `pxe-images` + `windows-projects` writable by `camp` user
- enforcer staged at `/home/camp/pxe-images/enforcer-stage/` (via
`cp <repo>/common/GE-Enforce.ps1 <repo>/common/lib/Install-FromManifest.ps1
/home/camp/pxe-images/enforcer-stage/`)
- v2 share staging at `/home/camp/pxe-images/tsgwp00525-v2/...`
## Usage
From the repo root on the host:
```bash
# Round 1: Shopfloor scope (exercises common manifest, PCTypes filter for Oracle)
B64=$(iconv -f UTF-8 -t UTF-16LE common/test/vm-test-harness.ps1 | base64 -w0)
/tmp/guest-exec.sh powershell.exe "[\"-NoProfile\",\"-EncodedCommand\",\"$B64\"]"
```
Or with non-default pcType (wrap in a tiny outer script that sets parameters):
```bash
cat > /tmp/round.ps1 <<'EOF'
$PCType = 'Standard'
$PCSubType = 'Machine'
EOF
sed -n '/^param(/,/^)/!p' common/test/vm-test-harness.ps1 >> /tmp/round.ps1
B64=$(iconv -f UTF-8 -t UTF-16LE /tmp/round.ps1 | base64 -w0)
/tmp/guest-exec.sh powershell.exe "[\"-NoProfile\",\"-EncodedCommand\",\"$B64\"]"
```
## What each round validates
| Round | pcType / pcSubType | Exercises |
|---|---|---|
| 1 | Shopfloor / — | common manifest only, PCTypes filter (Oracle skips) |
| 2 | Standard / Machine | common + standard-machine manifests, eDNC upgrade detection, UDC skip, eMxInfo cmd |
| 3 | Keyence / — | common + keyence manifest, VR-6000 MSI detection, pnputil INF detection |
| 4 | Display / — | common + display manifest, kiosk-setup CMD wrapper |
| 5 (composite) | Shopfloor with a corrupted manifest / bad SFLD creds / tampered local XML | graceful-degradation paths + upgrade/rollback via hash mismatch |
See the main repo enforcer design doc (TBD) for scenario details.
## Known cleanup after test runs
- The harness intentionally leaves installed apps in place (Acrobat Reader DC,
WJF Defect Tracker, 3OF9 font, Edge site-list XML, Firefox if tested).
To reset to a clean baseline, revert the VM to the `clean-base` libvirt
snapshot: `virsh snapshot-revert win11 clean-base`.
- Orphan `msiexec.exe` workers from long-running installs (UDC_Setup,
PC-DMIS) can leave the MSI mutex held, blocking the next install with
1619/1618. Between rounds if you hit this:
```powershell
Get-Process -Name msiexec -ErrorAction SilentlyContinue | Stop-Process -Force
```
Note: a Stage 2b lib improvement is planned to retry once on 1618 after
killing stale msiexec processes.

View File

@@ -0,0 +1,62 @@
param(
[string]$PCType = 'Shopfloor',
[string]$PCSubType = ''
)
$ErrorActionPreference = 'Continue'
Write-Host '================================================================'
Write-Host "=== VM test harness: pcType=$PCType subType=$PCSubType ==="
Write-Host '================================================================'
& net use '\\192.168.122.1\pxe-images' /user:camp vos313 2>&1 | Out-Null
Write-Host "SMB mount status: $LASTEXITCODE"
Write-Host ''
Write-Host '[setup] cleaning prior test state'
Remove-Item -Recurse -Force 'C:\Enrollment' -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force 'C:\Program Files\GE\Shopfloor' -ErrorAction SilentlyContinue
Remove-Item -Recurse -Force 'C:\Logs\Shopfloor' -ErrorAction SilentlyContinue
Remove-Item -Path 'HKLM:\SOFTWARE\GE\SFLD\Credentials\samba' -Recurse -Force -ErrorAction SilentlyContinue
Write-Host '[setup] staging enforcer runtime'
New-Item -Path 'C:\Program Files\GE\Shopfloor\lib' -ItemType Directory -Force | Out-Null
Copy-Item '\\192.168.122.1\pxe-images\enforcer-stage\GE-Enforce.ps1' 'C:\Program Files\GE\Shopfloor\GE-Enforce.ps1' -Force
Copy-Item '\\192.168.122.1\pxe-images\enforcer-stage\lib\Install-FromManifest.ps1' 'C:\Program Files\GE\Shopfloor\lib\Install-FromManifest.ps1' -Force
Write-Host "[setup] creating enrollment stubs (pcType=$PCType subType=$PCSubType)"
New-Item -Path 'C:\Enrollment' -ItemType Directory -Force | Out-Null
$PCType | Set-Content -Path 'C:\Enrollment\pc-type.txt' -Encoding ascii
$PCSubType | Set-Content -Path 'C:\Enrollment\pc-subtype.txt' -Encoding ascii
$siteConfig = @{
shopfloorShareRoot = '\\192.168.122.1\pxe-images\tsgwp00525-v2\shared\dt\shopfloor'
siteName = 'West Jefferson (VM test)'
} | ConvertTo-Json -Depth 5
$siteConfig | Set-Content -Path 'C:\Enrollment\site-config.json' -Encoding ascii
Write-Host '[setup] injecting fake SFLD creds'
$credKey = 'HKLM:\SOFTWARE\GE\SFLD\Credentials\samba'
New-Item -Path $credKey -Force | Out-Null
New-ItemProperty -Path $credKey -Name TargetHost -Value '192.168.122.1' -PropertyType String -Force | Out-Null
New-ItemProperty -Path $credKey -Name Username -Value 'camp' -PropertyType String -Force | Out-Null
New-ItemProperty -Path $credKey -Name Password -Value 'vos313' -PropertyType String -Force | Out-Null
Write-Host ''
Write-Host '================================================================'
Write-Host '=== Running GE-Enforce.ps1 ==='
Write-Host '================================================================'
$stdoutFile = [System.IO.Path]::GetTempFileName()
$stderrFile = [System.IO.Path]::GetTempFileName()
$argString = '-NoProfile -ExecutionPolicy Bypass -File "C:\Program Files\GE\Shopfloor\GE-Enforce.ps1"'
$proc = Start-Process -FilePath 'powershell.exe' -ArgumentList $argString `
-NoNewWindow -Wait -PassThru `
-RedirectStandardOutput $stdoutFile -RedirectStandardError $stderrFile
$rc = $proc.ExitCode
Write-Host "--- GE-Enforce stdout ---"
Get-Content -Path $stdoutFile -ErrorAction SilentlyContinue
Write-Host "--- GE-Enforce stderr ---"
Get-Content -Path $stderrFile -ErrorAction SilentlyContinue
Remove-Item $stdoutFile, $stderrFile -Force -ErrorAction SilentlyContinue
Write-Host ''
Write-Host "GE-Enforce exited $rc"

View File

@@ -1,24 +1,23 @@
#!/bin/bash #!/bin/bash
# #
# mirror-from-gold.sh - Replicate content from an existing PXE server (GOLD) # mirror-from-gold.sh - Byte-identical mirror of /srv/samba/ from an existing
# onto a freshly-installed PXE server. # PXE server (GOLD) onto a freshly-installed PXE server.
# #
# Run this ON THE NEW PXE SERVER, pointing at the GOLD server's IP. # Run this ON THE NEW PXE SERVER, pointing at the GOLD server's IP.
# It pulls Operating Systems, drivers, packages, custom installers, and # It pulls every Samba share (winpeapps, enrollment, blancco-reports,
# Blancco assets that are NOT bundled on the USB installer. # clonezilla) wholesale so the new box matches GOLD regardless of whether
# # content lives in the flat layout or the new taxonomy layout.
# GOLD is on the taxonomy layout (pre-install/, installers-post/, blancco/,
# config/, ppkgs/, scripts/, shopfloor-setup/). Source and destination paths
# are identical per section, so sections can be added trivially as GOLD grows.
# #
# Usage: # Usage:
# sudo ./mirror-from-gold.sh <GOLD_IP> [options] # sudo ./mirror-from-gold.sh <GOLD_IP> [options]
# #
# Options: # Options:
# --skip-drivers Do not mirror Dell driver tree (saves ~178G). # --skip-drivers Do not mirror Out-of-box Drivers trees (saves ~178G).
# --skip-dell10 Do not mirror Dell_10 drivers (saves ~179G). # --skip-dell10 Do not mirror Dell_10 drivers (saves ~179G).
# --skip-latitude Do not mirror Latitude drivers (saves ~48G). # --skip-latitude Do not mirror Latitude drivers (saves ~48G).
# --skip-os Do not mirror Operating Systems (saves ~22G). # --skip-os Do not mirror shared Operating Systems (saves ~22G).
# --skip-clonezilla Do not mirror clonezilla backup images (can be huge).
# --skip-reports Do not mirror blancco-reports history.
# --dry-run Show what would transfer without doing it. # --dry-run Show what would transfer without doing it.
# #
# Prereqs: # Prereqs:
@@ -49,6 +48,8 @@ SKIP_DRIVERS=0
SKIP_DELL10=0 SKIP_DELL10=0
SKIP_LATITUDE=0 SKIP_LATITUDE=0
SKIP_OS=0 SKIP_OS=0
SKIP_CLONEZILLA=0
SKIP_REPORTS=0
DRY_RUN="" DRY_RUN=""
while [ $# -gt 0 ]; do while [ $# -gt 0 ]; do
@@ -57,6 +58,8 @@ while [ $# -gt 0 ]; do
--skip-dell10) SKIP_DELL10=1 ;; --skip-dell10) SKIP_DELL10=1 ;;
--skip-latitude) SKIP_LATITUDE=1 ;; --skip-latitude) SKIP_LATITUDE=1 ;;
--skip-os) SKIP_OS=1 ;; --skip-os) SKIP_OS=1 ;;
--skip-clonezilla) SKIP_CLONEZILLA=1 ;;
--skip-reports) SKIP_REPORTS=1 ;;
--dry-run) DRY_RUN="--dry-run" ;; --dry-run) DRY_RUN="--dry-run" ;;
*) echo "Unknown option: $1"; exit 1 ;; *) echo "Unknown option: $1"; exit 1 ;;
esac esac
@@ -104,84 +107,42 @@ mirror() {
echo " WARNING: rsync exited rc=$? (likely a permissions issue on source); continuing" echo " WARNING: rsync exited rc=$? (likely a permissions issue on source); continuing"
} }
# ---------- Shared imaging content (winpeapps/_shared) ---------- # ---------- winpeapps share (all image types + _shared) ----------
DRIVER_EXCLUDES=() WINPE_EXCLUDES=()
[ "$SKIP_DELL10" = "1" ] && DRIVER_EXCLUDES+=(--exclude='Dell_10') [ "$SKIP_OS" = "1" ] && WINPE_EXCLUDES+=(--exclude='_shared/gea-Operating Systems')
[ "$SKIP_LATITUDE" = "1" ] && DRIVER_EXCLUDES+=(--exclude='Latitude') [ "$SKIP_DRIVERS" = "1" ] && WINPE_EXCLUDES+=(--exclude='_shared/Out-of-box Drivers' --exclude='Out-of-box Drivers')
[ "$SKIP_DELL10" = "1" ] && WINPE_EXCLUDES+=(--exclude='Dell_10')
[ "$SKIP_LATITUDE" = "1" ] && WINPE_EXCLUDES+=(--exclude='Latitude')
if [ "$SKIP_OS" = "0" ]; then mirror "winpeapps (all image types, _shared, tools)" \
mirror "Operating Systems (gea-Operating Systems)" \ "/srv/samba/winpeapps/" \
"/srv/samba/winpeapps/_shared/gea-Operating Systems/" \ "/srv/samba/winpeapps/" \
"/srv/samba/winpeapps/_shared/gea-Operating Systems/" "${WINPE_EXCLUDES[@]}"
# ---------- enrollment share (flat-layout root + taxonomy subdirs) ----------
mirror "enrollment (taxonomy + flat-layout content)" \
"/srv/samba/enrollment/" \
"/srv/samba/enrollment/"
# ---------- blancco-reports share (historical XML reports) ----------
if [ "$SKIP_REPORTS" = "0" ]; then
mirror "blancco-reports (erasure report history)" \
"/srv/samba/blancco-reports/" \
"/srv/samba/blancco-reports/"
fi fi
mirror "Packages (gea-Packages)" \ # ---------- clonezilla share (disk backup images, can be very large) ----------
"/srv/samba/winpeapps/_shared/gea-Packages/" \ if [ "$SKIP_CLONEZILLA" = "0" ]; then
"/srv/samba/winpeapps/_shared/gea-Packages/" mirror "clonezilla (disk backup images)" \
"/srv/samba/clonezilla/" \
mirror "Sources (gea-Sources)" \ "/srv/samba/clonezilla/"
"/srv/samba/winpeapps/_shared/gea-Sources/" \
"/srv/samba/winpeapps/_shared/gea-Sources/"
if [ "$SKIP_DRIVERS" = "0" ]; then
mirror "Out-of-box Drivers" \
"/srv/samba/winpeapps/_shared/Out-of-box Drivers/" \
"/srv/samba/winpeapps/_shared/Out-of-box Drivers/" \
"${DRIVER_EXCLUDES[@]}"
fi fi
# ---------- Per-image-type Deploy/ trees (mostly symlinks to _shared) ---------- # Permissions: make sure everything we pulled is readable by the share
for img in gea-standard gea-engineer gea-shopfloor; do chown -R root:root /srv/samba/enrollment /srv/samba/winpeapps /srv/samba/blancco-reports /srv/samba/clonezilla 2>/dev/null || true
mirror "Image dir: $img" \
"/srv/samba/winpeapps/$img/" \
"/srv/samba/winpeapps/$img/" \
--exclude=backup --exclude=logs --exclude='New folder' --exclude=ProMax
done
# ---------- Enrollment-share content (GOLD is on taxonomy layout, src == dest) ----------
# Pre-install: installers + bios + preinstall.json + udc-backups
mirror "Pre-install tree" \
"/srv/samba/enrollment/pre-install/" \
"/srv/samba/enrollment/pre-install/" \
--exclude='*.old'
# Post-install installers (CMM PC-DMIS, etc.)
mirror "Post-install installers" \
"/srv/samba/enrollment/installers-post/" \
"/srv/samba/enrollment/installers-post/"
# Blancco custom image
mirror "Blancco images" \
"/srv/samba/enrollment/blancco/" \
"/srv/samba/enrollment/blancco/"
# Site config (site-config.json, etc.)
mirror "Enrollment config" \
"/srv/samba/enrollment/config/" \
"/srv/samba/enrollment/config/"
# Provisioning packages
mirror "PPKGs" \
"/srv/samba/enrollment/ppkgs/" \
"/srv/samba/enrollment/ppkgs/"
# Enrollment scripts (run-enrollment.ps1, startnet.cmd, etc.)
mirror "Enrollment scripts" \
"/srv/samba/enrollment/scripts/" \
"/srv/samba/enrollment/scripts/" \
--exclude='*.pre-*'
# Shopfloor-setup tree (per-PC-type scripts + site-config)
mirror "Shopfloor-setup tree" \
"/srv/samba/enrollment/shopfloor-setup/" \
"/srv/samba/enrollment/shopfloor-setup/"
# Permissions: anything we just created should be readable by the share
chown -R root:root /srv/samba/enrollment /srv/samba/winpeapps
find /srv/samba/enrollment /srv/samba/winpeapps -type d -exec chmod 0755 {} \; 2>/dev/null || true find /srv/samba/enrollment /srv/samba/winpeapps -type d -exec chmod 0755 {} \; 2>/dev/null || true
find /srv/samba/enrollment /srv/samba/winpeapps -type f -exec chmod 0644 {} \; 2>/dev/null || true find /srv/samba/enrollment /srv/samba/winpeapps -type f -exec chmod 0644 {} \; 2>/dev/null || true
find /srv/samba/enrollment/ppkgs -name '*.ppkg' -exec chmod 0755 {} \; 2>/dev/null || true find /srv/samba/enrollment -name '*.ppkg' -exec chmod 0755 {} \; 2>/dev/null || true
echo echo
echo "============================================" echo "============================================"