Test bays reported 0 installed / 0 skipped / 0 failed and nothing was being processed. The share is a TRANSIENT mount: GE-Enforce.ps1 maps it for the length of its cycle and unmounts at the end - mounted 12:10:02, unmounted 12:10:27 on FB9TP7V3. The shadow task ran on its own 15-minute schedule, so it woke with the drive gone, handed the engine a path that no longer resolved, and the summary zero-filled. A silent nothing, indistinguishable from a healthy no-op, which is why it read as "not processing" rather than as a failure. Register-ShopdbShadow.ps1 is replaced by Invoke-ShopdbShadow.ps1, which runs AS a manifest entry and invokes the runner directly. The share is mounted because the enforce cycle is what called us; there is one cadence instead of two; and there is no task to register, drift, or heal. It also unregisters the superseded task, so bays that already carry it clean themselves up rather than keeping a run that cannot see the share and overwrites the real result with 0/0/0. The runner gains the Test-Path guard that should have been there: an unreachable -ShareManifestPath now logs, writes an event, and reports a real failure naming the reason, instead of running the engine against nothing. That silent zero is what made this expensive to find. Verified on the win11 VM with a substituted drive removed between runs - the same call gives real counts while mapped and the guard when not. The earlier task self-heal is moot now; it was a fix at the wrong layer, and testing against a local path is what hid a mount I had already noted was dynamic.
228 lines
11 KiB
PowerShell
228 lines
11 KiB
PowerShell
<#
|
|
.SYNOPSIS
|
|
Reference orchestrator: source a GE-Enforce manifest from shopdb, run the
|
|
UNCHANGED engine against it, and report the result back to shopdb.
|
|
|
|
This is a thin wrapper around Install-FromManifest.ps1 (the engine). It is a
|
|
reference a site adapts into its GE-Enforce.ps1 flow; it is not the live
|
|
dispatcher. The engine, detection, self-heal, and SMB payload resolution are
|
|
untouched - only the source of the manifest JSON moves from a share file to
|
|
shopdb, plus a result report.
|
|
|
|
.PARAMETER Scope
|
|
The imaging pc-type / scope name (e.g. gea-shopfloor-cmm), same value the
|
|
dispatcher already resolves from C:\Enrollment\pc-type.txt.
|
|
|
|
.PARAMETER EnginePath
|
|
Path to Install-FromManifest.ps1 (the engine lib, >= 2.6).
|
|
|
|
.PARAMETER ShareManifestPath
|
|
The current on-share manifest for this scope. In shadow mode the engine runs
|
|
against THIS (unchanged behavior) and shopdb is only compared + reported. Once
|
|
cut over, omit it and the engine runs against the shopdb-sourced manifest.
|
|
|
|
.PARAMETER ShadowMode
|
|
Fetch + compare + report, but install from the share (no behavior change).
|
|
|
|
.PARAMETER IncludeCommon
|
|
Also fetch the fleet-wide CommonScope and merge it into -Scope, so the PC
|
|
enforces its own scope entries PLUS common's (on a Name conflict the -Scope
|
|
entry wins). OFF by default: a scope is enforced ALONE (self-sufficient).
|
|
Displays are self-sufficient and do NOT set this. Only a share-less non-display
|
|
PC that genuinely needs the fleet-wide common entries over HTTPS turns it on.
|
|
|
|
.PARAMETER CommonScope
|
|
Name of the fleet-wide scope merged when -IncludeCommon is set (default
|
|
'common'). Ignored without -IncludeCommon.
|
|
|
|
.NOTES
|
|
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
|
|
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
|
|
#>
|
|
[CmdletBinding()]
|
|
param(
|
|
[Parameter(Mandatory)] [string]$Scope,
|
|
[Parameter(Mandatory)] [string]$EnginePath,
|
|
[string]$ShareManifestPath,
|
|
[switch]$ShadowMode,
|
|
[switch]$IncludeCommon,
|
|
[string]$CommonScope = 'common',
|
|
[string]$BaseUrl,
|
|
[string]$ApiToken,
|
|
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
|
|
)
|
|
|
|
function Write-Log {
|
|
param([string]$Message, [string]$Level = 'INFO')
|
|
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
|
|
try {
|
|
$logDir = Split-Path -Parent $LogFile
|
|
if ($logDir -and -not (Test-Path $logDir)) {
|
|
New-Item -ItemType Directory -Path $logDir -Force -ErrorAction SilentlyContinue | Out-Null
|
|
}
|
|
Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue
|
|
} catch {}
|
|
Write-Host $line
|
|
}
|
|
|
|
function Write-ShopdbEventLog {
|
|
<#
|
|
Write a Windows Application event-log entry under source 'ShopdbEnforce'.
|
|
Used to make an otherwise silent fail-safe (no manifest and an empty cache)
|
|
observable to whoever watches the display. Best-effort: registering the
|
|
source needs admin, which the SYSTEM scheduled task has; any failure is
|
|
swallowed so it can never break the fail-safe.
|
|
#>
|
|
param([string]$Message,
|
|
[string]$EntryType = 'Warning',
|
|
[int]$EventId = 1001)
|
|
$source = 'ShopdbEnforce'
|
|
try {
|
|
if (-not [System.Diagnostics.EventLog]::SourceExists($source)) {
|
|
New-EventLog -LogName Application -Source $source -ErrorAction Stop
|
|
}
|
|
Write-EventLog -LogName Application -Source $source -EntryType $EntryType `
|
|
-EventId $EventId -Message $Message -ErrorAction Stop
|
|
} catch {}
|
|
}
|
|
|
|
try {
|
|
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
|
|
|
|
$config = Get-ShopdbConfig -BaseUrl $BaseUrl -ApiToken $ApiToken
|
|
if (-not $config) {
|
|
Write-Log 'No shopdb BaseUrl configured yet - retry next cycle.' 'WARN'
|
|
exit 0
|
|
}
|
|
|
|
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
|
|
if (-not $sync.Path) {
|
|
# Fail-safe stays (exit 0), but a fresh display with an empty cache would
|
|
# otherwise enforce nothing SILENTLY. Surface it: a Windows event-log
|
|
# entry plus a best-effort report ping so it shows in Enforcement Reports.
|
|
$reason = if ($sync.Error) { $sync.Error } else { 'shopdb unreachable and no cached manifest' }
|
|
Write-Log "No manifest available for $Scope ($reason)." 'WARN'
|
|
Write-ShopdbEventLog -Message ("GE-Enforce could not fetch a manifest for scope '$Scope' and has no cached copy; nothing was enforced this cycle. Reason: $reason") -EntryType 'Error' -EventId 1001
|
|
try {
|
|
$failReport = New-ShopdbReport -Scope $Scope -AppliedVersion 0 -Summary @{
|
|
Installed = 0; Skipped = 0; Failed = 1; Filtered = 0; EnforcerVersion = '2.6'
|
|
Results = @(@{ Name = '(manifest-fetch)'; Action = 'failed'; Message = $reason })
|
|
}
|
|
if (Send-ShopdbReport -Config $config -Report $failReport) {
|
|
Write-Log 'Reported empty-cache fetch failure to shopdb.'
|
|
}
|
|
} catch {}
|
|
exit 0
|
|
}
|
|
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
|
|
|
|
# Shadow mode: compare shopdb vs the share, but install from the share.
|
|
if ($ShadowMode -and $ShareManifestPath -and (Test-Path $ShareManifestPath)) {
|
|
$diff = Compare-ShopdbShadow -ShopdbManifestPath $sync.Path -ShareManifestPath $ShareManifestPath
|
|
if ($diff.Same) {
|
|
Write-Log 'Shadow: shopdb manifest matches the share.'
|
|
} else {
|
|
Write-Log ("Shadow DIFF: shopdb-only=[{0}] share-only=[{1}] orderDiff={2}" -f `
|
|
($diff.ShopdbOnly -join ','), ($diff.ShareOnly -join ','), $diff.OrderDiff) 'WARN'
|
|
}
|
|
}
|
|
|
|
# Which manifest the engine actually runs against.
|
|
if ($ShadowMode -and $ShareManifestPath) {
|
|
# Shadow: install from the share exactly as today (no payload resolve,
|
|
# no common merge - the share already carries its own common scope).
|
|
#
|
|
# GUARDED, because the share is a TRANSIENT mount. GE-Enforce.ps1 maps it
|
|
# for the length of its cycle and unmounts at the end, so a caller that
|
|
# runs on its own schedule finds the drive gone. Handing the engine a
|
|
# path on a dead drive returned nothing usable and the summary zero-filled
|
|
# to 0 installed / 0 skipped / 0 failed - a silent nothing that reads
|
|
# exactly like a healthy no-op. Shadow must run INSIDE the enforce cycle;
|
|
# this makes the alternative loud instead of invisible.
|
|
if (-not (Test-Path -LiteralPath $ShareManifestPath)) {
|
|
$reason = "share manifest not reachable at $ShareManifestPath " +
|
|
'(is the share still mounted? shadow must run inside the ' +
|
|
'enforce cycle)'
|
|
Write-Log $reason 'WARN'
|
|
Write-ShopdbEventLog -Message ("GE-Enforce shadow could not reach the share manifest for scope '$Scope': $reason") -EntryType 'Error' -EventId 1002
|
|
try {
|
|
$failReport = New-ShopdbReport -Scope $Scope -AppliedVersion 0 -Summary @{
|
|
Installed = 0; Skipped = 0; Failed = 1; Filtered = 0; EnforcerVersion = '2.6'
|
|
Results = @(@{ Name = '(share-manifest)'; Action = 'failed'; Message = $reason })
|
|
}
|
|
Send-ShopdbReport -Config $config -Report $failReport | Out-Null
|
|
} catch {}
|
|
exit 0
|
|
}
|
|
$manifestToRun = $ShareManifestPath
|
|
} else {
|
|
# Optional common-scope inheritance (OFF by default; displays are
|
|
# self-sufficient). Only when -IncludeCommon is set do we fetch the
|
|
# fleet-wide common scope (best-effort, same fail-safe cache) and merge
|
|
# it in with the pctype winning on conflict. Skipped when this run IS
|
|
# the common scope.
|
|
$manifestToMerge = $sync.Path
|
|
if ($IncludeCommon -and $CommonScope -and ($CommonScope -ine $Scope)) {
|
|
$commonSync = Sync-ShopdbManifest -Scope $CommonScope -Config $config
|
|
if ($commonSync.Path) {
|
|
$manifestToMerge = Merge-ShopdbManifests -PrimaryManifestPath $sync.Path -CommonManifestPath $commonSync.Path
|
|
if ($manifestToMerge -ne $sync.Path) {
|
|
Write-Log "Merged common scope '$CommonScope' (from $($commonSync.Source), v$($commonSync.Version)) into $Scope."
|
|
}
|
|
} else {
|
|
Write-Log "Common scope '$CommonScope' unavailable (no fetch, no cache) - enforcing $Scope alone." 'WARN'
|
|
}
|
|
}
|
|
|
|
# Cutover: stage any http/inline payloads to local files and rewrite the
|
|
# manifest to point at them, so the UNCHANGED engine installs from local
|
|
# (no SMB needed for share-less PCs).
|
|
$manifestToRun = Resolve-ShopdbPayloads -ManifestPath $manifestToMerge -Config $config
|
|
if ($manifestToRun -ne $manifestToMerge) {
|
|
Write-Log "Resolved http/inline payloads to local files: $manifestToRun"
|
|
}
|
|
}
|
|
|
|
# --- INTEGRATION POINT ---------------------------------------------------
|
|
# Run the engine. EXPECTED ENGINE CONTRACT: Install-FromManifest.ps1 returns
|
|
# a summary object (hashtable or PSCustomObject) carrying integer counts
|
|
# Installed / Skipped / Failed / Filtered
|
|
# a string EnforcerVersion, and a Results list of per-entry outcomes
|
|
# @{ Name; Action; SelfHealed; ExitCode; Message }.
|
|
# The engine may not honor that yet: it might return $null, a bare return
|
|
# code, or emit several objects. ConvertTo-ShopdbSummary adapts whatever it
|
|
# returns into a well-formed summary hashtable so the report stage always
|
|
# gets clean input (we do NOT assume the engine was fixed).
|
|
# The engine requires -InstallerRoot (base for any relative Source/Installer
|
|
# path) and -LogFile. Shadow runs off the share, so relative paths resolve
|
|
# against the share scope dir. Cutover rewrites payloads to ABSOLUTE local
|
|
# paths, so InstallerRoot is only a harmless fallback base (the payload cache).
|
|
if ($ShadowMode -and $ShareManifestPath) {
|
|
$installerRoot = Split-Path -Parent $ShareManifestPath
|
|
} else {
|
|
$installerRoot = Join-Path (Split-Path -Parent $manifestToRun) 'payloads'
|
|
}
|
|
if ($installerRoot -and -not (Test-Path $installerRoot)) {
|
|
New-Item -ItemType Directory -Path $installerRoot -Force -ErrorAction SilentlyContinue | Out-Null
|
|
}
|
|
$engineLog = $LogFile -replace '\.log$', '-engine.log'
|
|
|
|
Write-Log "Running engine against $manifestToRun"
|
|
$engineResult = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope `
|
|
-InstallerRoot $installerRoot -LogFile $engineLog
|
|
$summary = ConvertTo-ShopdbSummary -EngineResult $engineResult
|
|
|
|
# Report the result (best-effort).
|
|
$appliedVersion = 0
|
|
if ($sync.Version) { [int]::TryParse($sync.Version, [ref]$appliedVersion) | Out-Null }
|
|
$report = New-ShopdbReport -Scope $Scope -AppliedVersion $appliedVersion -Summary $summary
|
|
if (Send-ShopdbReport -Config $config -Report $report) {
|
|
Write-Log "Reported: installed=$($summary.Installed) skipped=$($summary.Skipped) failed=$($summary.Failed)."
|
|
} else {
|
|
Write-Log 'Report POST failed (non-fatal).' 'WARN'
|
|
}
|
|
} catch {
|
|
Write-Log "Unhandled error (non-fatal): $($_.Exception.Message)" 'ERROR'
|
|
}
|
|
exit 0
|