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:
@@ -286,13 +286,47 @@ Unregister-ScheduledTask -TaskName 'GE Re-enable Wired NICs' -Confirm:$false -Er
|
||||
}
|
||||
|
||||
$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'
|
||||
if (Test-Path -LiteralPath $registerCommon) {
|
||||
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: $_" }
|
||||
} else {
|
||||
Write-Host "Register-CommonEnforce.ps1 not found (optional) - skipping"
|
||||
}
|
||||
|
||||
# Map S: drive on user logon for every account in BUILTIN\Users. The
|
||||
|
||||
@@ -14,14 +14,14 @@
|
||||
"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)",
|
||||
"Installer": "eDNC-6.4.3.msi",
|
||||
"Installer": "eDNC_6-4-5.msi",
|
||||
"Type": "MSI",
|
||||
"InstallArgs": "/qn /norestart ALLUSERS=1 REBOOT=ReallySuppress SITESELECTED=\"West Jefferson\"",
|
||||
"DetectionMethod": "FileVersion",
|
||||
"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).",
|
||||
|
||||
97
playbook/shopfloor-setup/common/Deploy-GEEnforce.ps1
Normal file
97
playbook/shopfloor-setup/common/Deploy-GEEnforce.ps1
Normal 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'
|
||||
247
playbook/shopfloor-setup/common/GE-Enforce.ps1
Normal file
247
playbook/shopfloor-setup/common/GE-Enforce.ps1
Normal 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
|
||||
101
playbook/shopfloor-setup/common/Register-GEEnforce.ps1
Normal file
101
playbook/shopfloor-setup/common/Register-GEEnforce.ps1
Normal 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"
|
||||
@@ -1,30 +1,49 @@
|
||||
# Install-FromManifest.ps1 - Generic JSON-manifest installer for cross-PC-type
|
||||
# apps enforced from the SFLD share (Acrobat Reader DC today; others later).
|
||||
# Install-FromManifest.ps1 - Generic JSON-manifest runner for shopfloor apps.
|
||||
#
|
||||
# Duplicated from CMM\lib\Install-FromManifest.ps1 with a few differences:
|
||||
# - adds Type=CMD (cmd.exe /c wrapper, needed for Acrobat's two-step
|
||||
# MSI + MSP install that the vendor ships as Install-AcroReader.cmd)
|
||||
# - unchanged otherwise; a future pass will unify both libraries.
|
||||
# Stage 2a extension: adds PS1, BAT, File, Registry, INF action types and
|
||||
# Always / MarkerFile / ValueMatches / pnputil detection methods. Also adds
|
||||
# optional PCTypes filtering (entry skipped if current pc-type.txt doesn't
|
||||
# match any value in the entry's PCTypes array).
|
||||
#
|
||||
# Called from:
|
||||
# - Acrobat-Enforce.ps1 on logon with InstallerRoot=<mounted tsgwp00525 share>
|
||||
# Manifest-schema fields parsed-but-not-yet-acted-on (Stage 2b will wire them):
|
||||
# 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
|
||||
# or installed successfully; non-zero if any install failed.
|
||||
# Consumers:
|
||||
# - 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(
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$ManifestPath,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$InstallerRoot,
|
||||
|
||||
[Parameter(Mandatory=$true)]
|
||||
[string]$LogFile
|
||||
[Parameter(Mandatory=$true)] [string]$ManifestPath,
|
||||
[Parameter(Mandatory=$true)] [string]$InstallerRoot,
|
||||
[Parameter(Mandatory=$true)] [string]$LogFile,
|
||||
[Parameter(Mandatory=$false)] [string]$PCType,
|
||||
[Parameter(Mandatory=$false)] [string]$PCSubType
|
||||
)
|
||||
|
||||
$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
|
||||
if (-not (Test-Path $logDir)) {
|
||||
New-Item -Path $logDir -ItemType Directory -Force | Out-Null
|
||||
@@ -32,13 +51,12 @@ if (-not (Test-Path $logDir)) {
|
||||
|
||||
function Write-InstallLog {
|
||||
param(
|
||||
[Parameter(Mandatory=$true, Position=0)]
|
||||
[string]$Message,
|
||||
[Parameter(Mandatory=$true, Position=0)] [string]$Message,
|
||||
[Parameter(Position=1)]
|
||||
[ValidateSet('INFO','WARN','ERROR')]
|
||||
[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"
|
||||
Write-Host $line
|
||||
try {
|
||||
@@ -59,36 +77,60 @@ function Write-InstallLog {
|
||||
}
|
||||
}
|
||||
|
||||
Write-InstallLog "================================================================"
|
||||
Write-InstallLog '================================================================'
|
||||
Write-InstallLog "=== Install-FromManifest session start (PID $PID) ==="
|
||||
Write-InstallLog "Manifest: $ManifestPath"
|
||||
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)) {
|
||||
Write-InstallLog "Manifest not found: $ManifestPath" "ERROR"
|
||||
Write-InstallLog "Manifest not found: $ManifestPath" 'ERROR'
|
||||
exit 2
|
||||
}
|
||||
|
||||
if (-not (Test-Path -LiteralPath $InstallerRoot)) {
|
||||
Write-InstallLog "InstallerRoot not found: $InstallerRoot" "ERROR"
|
||||
Write-InstallLog "InstallerRoot not found: $InstallerRoot" 'ERROR'
|
||||
exit 2
|
||||
}
|
||||
|
||||
try {
|
||||
$config = Get-Content -LiteralPath $ManifestPath -Raw | ConvertFrom-Json
|
||||
} catch {
|
||||
Write-InstallLog "Failed to parse manifest: $_" "ERROR"
|
||||
Write-InstallLog "Failed to parse manifest: $_" 'ERROR'
|
||||
exit 2
|
||||
}
|
||||
|
||||
if (-not $config.Applications) {
|
||||
Write-InstallLog "No Applications in manifest - nothing to do"
|
||||
if (-not $config.Applications -or $config.Applications.Count -eq 0) {
|
||||
Write-InstallLog 'No Applications in manifest - nothing to do'
|
||||
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)"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Detection
|
||||
# ---------------------------------------------------------------------------
|
||||
function Test-AppInstalled {
|
||||
param($App)
|
||||
|
||||
@@ -96,7 +138,7 @@ function Test-AppInstalled {
|
||||
|
||||
try {
|
||||
switch ($App.DetectionMethod) {
|
||||
"Registry" {
|
||||
'Registry' {
|
||||
if (-not (Test-Path $App.DetectionPath)) { return $false }
|
||||
if ($App.DetectionName) {
|
||||
$value = Get-ItemProperty -Path $App.DetectionPath -Name $App.DetectionName -ErrorAction SilentlyContinue
|
||||
@@ -108,182 +150,333 @@ function Test-AppInstalled {
|
||||
}
|
||||
return $true
|
||||
}
|
||||
"File" {
|
||||
'File' {
|
||||
return Test-Path $App.DetectionPath
|
||||
}
|
||||
"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.
|
||||
'FileVersion' {
|
||||
if (-not (Test-Path $App.DetectionPath)) { return $false }
|
||||
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
|
||||
}
|
||||
$actual = (Get-Item $App.DetectionPath -ErrorAction Stop).VersionInfo.FileVersion
|
||||
if (-not $actual) { return $false }
|
||||
return ($actual -eq $App.DetectionValue)
|
||||
}
|
||||
"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.
|
||||
'Hash' {
|
||||
if (-not (Test-Path $App.DetectionPath)) { return $false }
|
||||
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
|
||||
}
|
||||
$actual = (Get-FileHash -Path $App.DetectionPath -Algorithm SHA256 -ErrorAction Stop).Hash
|
||||
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 {
|
||||
Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" "WARN"
|
||||
Write-InstallLog " Unknown detection method: $($App.DetectionMethod)" 'WARN'
|
||||
return $false
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
Write-InstallLog " Detection check threw: $_" "WARN"
|
||||
Write-InstallLog " Detection check threw: $_" 'WARN'
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Action dispatch
|
||||
# ---------------------------------------------------------------------------
|
||||
function Invoke-InstallerAction {
|
||||
param($App)
|
||||
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
||||
|
||||
switch ($App.Type) {
|
||||
'MSI' {
|
||||
$installerPath = Join-Path $InstallerRoot $App.Installer
|
||||
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"
|
||||
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
|
||||
$psi.FileName = 'msiexec.exe'
|
||||
$psi.Arguments = "/i `"$installerPath`""
|
||||
if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs }
|
||||
$psi.Arguments += " /L*v `"$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 }
|
||||
}
|
||||
$psi.FileName = $installerPath
|
||||
if ($App.InstallArgs) { $psi.Arguments = $App.InstallArgs }
|
||||
Write-InstallLog " exe: $installerPath"
|
||||
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 }
|
||||
}
|
||||
$psi.FileName = 'cmd.exe'
|
||||
$psi.Arguments = "/c `"$installerPath`""
|
||||
if ($App.InstallArgs) { $psi.Arguments += " " + $App.InstallArgs }
|
||||
Write-InstallLog " cmd /c $installerPath"
|
||||
$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 }
|
||||
}
|
||||
}
|
||||
'Registry' {
|
||||
# Write a single registry value.
|
||||
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) {
|
||||
cmd /c "shutdown /a 2>nul" *>$null
|
||||
# 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
|
||||
}
|
||||
|
||||
if (-not (Test-HostnameMatches -App $app)) {
|
||||
Write-InstallLog " TargetHostnames filter: entry targets $($app.TargetHostnames -join ',') but PC is $env:COMPUTERNAME - skipping"
|
||||
$pcFiltered++
|
||||
continue
|
||||
}
|
||||
|
||||
if (Test-AppInstalled -App $app) {
|
||||
Write-InstallLog " Already installed at expected version - skipping"
|
||||
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
|
||||
$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++
|
||||
|
||||
# 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 {
|
||||
Write-InstallLog " Exit $rc - FAILED" 'ERROR'
|
||||
|
||||
Write-InstallLog " Installing from $installerPath"
|
||||
if ($app.InstallArgs) {
|
||||
Write-InstallLog " InstallArgs: $($app.InstallArgs)"
|
||||
}
|
||||
|
||||
try {
|
||||
$psi = New-Object System.Diagnostics.ProcessStartInfo
|
||||
$psi.UseShellExecute = $false
|
||||
$psi.CreateNoWindow = $true
|
||||
$psi.WindowStyle = [System.Diagnostics.ProcessWindowStyle]::Hidden
|
||||
|
||||
$msiLog = $null
|
||||
|
||||
if ($app.Type -eq "MSI") {
|
||||
$safeName = $app.Name -replace '[^a-zA-Z0-9]','_'
|
||||
$msiLog = Join-Path $logDir "msi-$safeName.log"
|
||||
if (Test-Path $msiLog) { Remove-Item $msiLog -Force -ErrorAction SilentlyContinue }
|
||||
|
||||
$psi.FileName = "msiexec.exe"
|
||||
$psi.Arguments = "/i `"$installerPath`""
|
||||
if ($app.InstallArgs) { $psi.Arguments += " " + $app.InstallArgs }
|
||||
$psi.Arguments += " /L*v `"$msiLog`""
|
||||
Write-InstallLog " msiexec verbose log: $msiLog"
|
||||
}
|
||||
elseif ($app.Type -eq "EXE") {
|
||||
$psi.FileName = $installerPath
|
||||
if ($app.InstallArgs) { $psi.Arguments = $app.InstallArgs }
|
||||
if ($app.LogFile) {
|
||||
Write-InstallLog " Installer log: $($app.LogFile)"
|
||||
}
|
||||
}
|
||||
elseif ($app.Type -eq "CMD") {
|
||||
# .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`""
|
||||
if ($app.InstallArgs) { $psi.Arguments += " " + $app.InstallArgs }
|
||||
if ($app.LogFile) {
|
||||
Write-InstallLog " Installer log: $($app.LogFile)"
|
||||
}
|
||||
}
|
||||
else {
|
||||
Write-InstallLog " Unsupported Type: $($app.Type) - skipping" "ERROR"
|
||||
$failed++
|
||||
continue
|
||||
}
|
||||
|
||||
$proc = [System.Diagnostics.Process]::Start($psi)
|
||||
$proc.WaitForExit()
|
||||
$exitCode = $proc.ExitCode
|
||||
|
||||
if ($exitCode -eq 0 -or $exitCode -eq 1641 -or $exitCode -eq 3010) {
|
||||
Write-InstallLog " Exit code $exitCode - SUCCESS"
|
||||
if ($exitCode -eq 3010) { Write-InstallLog " (Reboot pending for $($app.Name))" }
|
||||
if ($exitCode -eq 1641) { Write-InstallLog " (Installer initiated a reboot for $($app.Name))" }
|
||||
$installed++
|
||||
}
|
||||
else {
|
||||
Write-InstallLog " Exit code $exitCode - FAILED" "ERROR"
|
||||
|
||||
if (($app.Type -eq "EXE" -or $app.Type -eq "CMD") -and $app.LogFile -and (Test-Path $app.LogFile)) {
|
||||
Write-InstallLog " --- last 30 lines of $($app.LogFile) ---"
|
||||
Get-Content $app.LogFile -Tail 30 -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
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 ---"
|
||||
if ($result.LogRef -and (Test-Path $result.LogRef)) {
|
||||
if ($app.Type -eq 'MSI') {
|
||||
Write-InstallLog " --- meaningful lines from $($result.LogRef) ---"
|
||||
$patterns = @(
|
||||
'Note: 1: ',
|
||||
'return value 3',
|
||||
'Error \d+\.',
|
||||
'CustomAction .* returned actual error',
|
||||
'Failed to ',
|
||||
'Installation failed',
|
||||
'1: 2262',
|
||||
'1: 2203',
|
||||
'1: 2330'
|
||||
'Note: 1: ', 'return value 3', 'Error \d+\.',
|
||||
'CustomAction .* returned actual error', 'Failed to ',
|
||||
'Installation failed', '1: 2262', '1: 2203', '1: 2330'
|
||||
)
|
||||
$regex = ($patterns -join '|')
|
||||
$matches = Select-String -Path $msiLog -Pattern $regex -ErrorAction SilentlyContinue |
|
||||
Select-Object -First 30
|
||||
$matches = Select-String -Path $result.LogRef -Pattern $regex -ErrorAction SilentlyContinue | Select-Object -First 30
|
||||
if ($matches) {
|
||||
foreach ($m in $matches) { Write-InstallLog " $($m.Line.Trim())" }
|
||||
} else {
|
||||
Get-Content $msiLog -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object {
|
||||
Write-InstallLog " $_"
|
||||
}
|
||||
Get-Content $result.LogRef -Tail 25 -ErrorAction SilentlyContinue | ForEach-Object { Write-InstallLog " $_" }
|
||||
}
|
||||
Write-InstallLog " --- end MSI log scan ---"
|
||||
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 ---'
|
||||
}
|
||||
|
||||
$failed++
|
||||
}
|
||||
} catch {
|
||||
Write-InstallLog " Install threw: $_" "ERROR"
|
||||
|
||||
$failed++
|
||||
}
|
||||
}
|
||||
|
||||
Write-InstallLog "============================================"
|
||||
Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed"
|
||||
Write-InstallLog "============================================"
|
||||
Write-InstallLog '============================================'
|
||||
Write-InstallLog "Install-FromManifest complete: $installed installed, $skipped skipped, $failed failed, $pcFiltered pc-filtered"
|
||||
Write-InstallLog '============================================'
|
||||
|
||||
cmd /c "shutdown /a 2>nul" *>$null
|
||||
cmd /c 'shutdown /a 2>nul' *>$null
|
||||
|
||||
if ($failed -gt 0) { exit 1 }
|
||||
exit 0
|
||||
|
||||
251
playbook/shopfloor-setup/common/monitor-fleet-status.py
Executable file
251
playbook/shopfloor-setup/common/monitor-fleet-status.py
Executable 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())
|
||||
77
playbook/shopfloor-setup/common/test/README.md
Normal file
77
playbook/shopfloor-setup/common/test/README.md
Normal 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.
|
||||
62
playbook/shopfloor-setup/common/test/vm-test-harness.ps1
Normal file
62
playbook/shopfloor-setup/common/test/vm-test-harness.ps1
Normal 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"
|
||||
@@ -1,24 +1,23 @@
|
||||
#!/bin/bash
|
||||
#
|
||||
# mirror-from-gold.sh - Replicate content from an existing PXE server (GOLD)
|
||||
# onto a freshly-installed PXE server.
|
||||
# mirror-from-gold.sh - Byte-identical mirror of /srv/samba/ from an existing
|
||||
# PXE server (GOLD) onto a freshly-installed PXE server.
|
||||
#
|
||||
# Run this ON THE NEW PXE SERVER, pointing at the GOLD server's IP.
|
||||
# It pulls Operating Systems, drivers, packages, custom installers, and
|
||||
# Blancco assets that are NOT bundled on the USB installer.
|
||||
#
|
||||
# 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.
|
||||
# It pulls every Samba share (winpeapps, enrollment, blancco-reports,
|
||||
# clonezilla) wholesale so the new box matches GOLD regardless of whether
|
||||
# content lives in the flat layout or the new taxonomy layout.
|
||||
#
|
||||
# Usage:
|
||||
# sudo ./mirror-from-gold.sh <GOLD_IP> [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-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.
|
||||
#
|
||||
# Prereqs:
|
||||
@@ -49,15 +48,19 @@ SKIP_DRIVERS=0
|
||||
SKIP_DELL10=0
|
||||
SKIP_LATITUDE=0
|
||||
SKIP_OS=0
|
||||
SKIP_CLONEZILLA=0
|
||||
SKIP_REPORTS=0
|
||||
DRY_RUN=""
|
||||
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--skip-drivers) SKIP_DRIVERS=1 ;;
|
||||
--skip-dell10) SKIP_DELL10=1 ;;
|
||||
--skip-latitude) SKIP_LATITUDE=1 ;;
|
||||
--skip-os) SKIP_OS=1 ;;
|
||||
--dry-run) DRY_RUN="--dry-run" ;;
|
||||
--skip-drivers) SKIP_DRIVERS=1 ;;
|
||||
--skip-dell10) SKIP_DELL10=1 ;;
|
||||
--skip-latitude) SKIP_LATITUDE=1 ;;
|
||||
--skip-os) SKIP_OS=1 ;;
|
||||
--skip-clonezilla) SKIP_CLONEZILLA=1 ;;
|
||||
--skip-reports) SKIP_REPORTS=1 ;;
|
||||
--dry-run) DRY_RUN="--dry-run" ;;
|
||||
*) echo "Unknown option: $1"; exit 1 ;;
|
||||
esac
|
||||
shift
|
||||
@@ -104,84 +107,42 @@ mirror() {
|
||||
echo " WARNING: rsync exited rc=$? (likely a permissions issue on source); continuing"
|
||||
}
|
||||
|
||||
# ---------- Shared imaging content (winpeapps/_shared) ----------
|
||||
DRIVER_EXCLUDES=()
|
||||
[ "$SKIP_DELL10" = "1" ] && DRIVER_EXCLUDES+=(--exclude='Dell_10')
|
||||
[ "$SKIP_LATITUDE" = "1" ] && DRIVER_EXCLUDES+=(--exclude='Latitude')
|
||||
# ---------- winpeapps share (all image types + _shared) ----------
|
||||
WINPE_EXCLUDES=()
|
||||
[ "$SKIP_OS" = "1" ] && WINPE_EXCLUDES+=(--exclude='_shared/gea-Operating Systems')
|
||||
[ "$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 "Operating Systems (gea-Operating Systems)" \
|
||||
"/srv/samba/winpeapps/_shared/gea-Operating Systems/" \
|
||||
"/srv/samba/winpeapps/_shared/gea-Operating Systems/"
|
||||
mirror "winpeapps (all image types, _shared, tools)" \
|
||||
"/srv/samba/winpeapps/" \
|
||||
"/srv/samba/winpeapps/" \
|
||||
"${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
|
||||
|
||||
mirror "Packages (gea-Packages)" \
|
||||
"/srv/samba/winpeapps/_shared/gea-Packages/" \
|
||||
"/srv/samba/winpeapps/_shared/gea-Packages/"
|
||||
|
||||
mirror "Sources (gea-Sources)" \
|
||||
"/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[@]}"
|
||||
# ---------- clonezilla share (disk backup images, can be very large) ----------
|
||||
if [ "$SKIP_CLONEZILLA" = "0" ]; then
|
||||
mirror "clonezilla (disk backup images)" \
|
||||
"/srv/samba/clonezilla/" \
|
||||
"/srv/samba/clonezilla/"
|
||||
fi
|
||||
|
||||
# ---------- Per-image-type Deploy/ trees (mostly symlinks to _shared) ----------
|
||||
for img in gea-standard gea-engineer gea-shopfloor; do
|
||||
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
|
||||
# Permissions: make sure everything we pulled is readable by the share
|
||||
chown -R root:root /srv/samba/enrollment /srv/samba/winpeapps /srv/samba/blancco-reports /srv/samba/clonezilla 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/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 "============================================"
|
||||
|
||||
Reference in New Issue
Block a user