Files
shopdb-flask/plugins/geenforce/client/Invoke-ShopdbShadow.ps1
cproudlock 035419fa51 ADR-015: stop shipping one site's values, and make the rule a gate
The scanner has been reporting the same count for weeks, which is what a rule
that only prints becomes. It now FAILS the build, and it looks where the leaks
actually were: PowerShell, the installer, the seeds, generated JSON, the
frontend - case-insensitively, across plugins, shopdb, scripts, deploy, tools.
A line that is deliberate declares itself with an ADR-015-OK marker and a
reason, so the claim is visible in review instead of tolerated in silence.

What it found, fixed here:

- The shadow client wrote one site's ShopDB URL into HKLM whenever the registry
  disagreed. At the site it was written for that reads as healing drift;
  anywhere else it overwrites the site's own address on every enforce cycle,
  and the site cannot win because the cycle repeats. The bay's value now wins,
  an explicit -BaseUrl seeds it, and with neither there is nothing honest to
  write, so it says so and skips.
- The kiosk dispatcher fell back to one plant's host when HKLM was unset, so a
  kiosk elsewhere quietly opened a server it has no business reaching. The
  fallback is now this site's site_base_url, baked in at seed time, and the
  dispatcher refuses rather than guessing when neither is set. Its legacy
  shortcut matcher derives the host from that URL instead of naming one.
- The OpenAPI generator hardcoded a production hostname into every spec it
  generated, which then published to a public wiki. The relative mount is the
  only server it can honestly name; a site passes its own by environment.
- Placeholders and examples in the UI and the client help offered real internal
  subnets and a real production URL. They now use documentation ranges.

Both publication gates - the export scrub and the docs publishability test -
carry the site patterns, which neither did. One plant's hostname, FQDN and
internal networks are out of the documentation and the generated specs.

Comments naming the reference site are reworded rather than deleted: the
reasoning is worth keeping, the plant name is not what makes it true.
2026-08-14 13:47:39 -04:00

129 lines
6.2 KiB
PowerShell

# Invoke-ShopdbShadow.ps1 -- run one shopdb SHADOW cycle, from the manifest.
#
# Shadow = fetch the shopdb manifest, diff it against the share manifest, report
# the cycle to shopdb. The engine still installs FROM THE SHARE, so behaviour is
# unchanged. It is the observable step before any cutover.
#
# WHY THIS RUNS AS A MANIFEST ENTRY AND NOT A SCHEDULED TASK
#
# The first version registered a separate 15-minute task. That cannot work:
# GE-Enforce.ps1 mounts the SFLD share for the length of its own cycle and
# unmounts it at the end (mounted 12:10:02, unmounted 12:10:27). A task on its
# own schedule therefore wakes up with the drive gone, hands the engine a path
# that no longer resolves, and reports 0 installed / 0 skipped / 0 failed - a
# silent nothing indistinguishable from a healthy no-op. It also meant two
# cadences that could drift apart, and a registration that had to be healed.
#
# Running here removes all of it: the share is mounted because the enforce cycle
# is what invoked us, the cadence is the fleet's own, and there is no task.
#
# Runs as SYSTEM under GE-Enforce, from an entry gated to the test bays.
# Fail-safe: exits 0 on every path - shadowing must never stop a bay enforcing.
param(
# This site's ShopDB. Optional: a bay that already carries a BaseUrl in the
# registry keeps it, so the fleet needs this only on the first cycle or when
# the address changes. There is deliberately NO default - see below.
[string] $BaseUrl = ''
)
$ErrorActionPreference = 'Continue'
# RE-ENTRANCY GUARD. This entry lives IN the manifest the shadow run enforces,
# so without it the thing recurses without bound: engine runs the manifest ->
# reaches this entry -> we invoke the runner -> the runner runs the engine
# against the SAME manifest -> reaches this entry again. Measured on the test
# bays 2026-08-13: 347 nested cycles in 36 minutes, one every five seconds,
# until the share unmounted. The task-based predecessor never hit this because
# it registered a task instead of invoking the runner.
#
# An environment variable, because it is inherited by every child process and
# therefore covers the nested engine and runner without a file to clean up or a
# stale lock to age out. Set for THIS process tree only.
if ($env:SHOPDB_SHADOW_ACTIVE -eq '1') { exit 0 }
$env:SHOPDB_SHADOW_ACTIVE = '1'
$InstallDir = 'C:\Program Files\GE\Shopfloor'
$LegacyTask = 'ShopDB GE-Enforce (shadow)'
function Write-ShadowLog {
# To a file as well as the host: the engine records only "ps1: <path>" and
# an exit code for a PS1 entry, so Write-Host reaches nothing, and with the
# fail-safe exit 0 a silent early-out looks exactly like success.
param([string]$Message)
$line = "[{0}] [shadow] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Host $line
try {
$dir = 'C:\Logs\Shopfloor'
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
Add-Content -LiteralPath (Join-Path $dir ('shadow-{0}.log' -f (Get-Date -Format yyyyMMdd))) -Value $line
} catch { }
}
try {
# Retire the scheduled task the earlier version left behind. Bays that got
# it would otherwise keep firing a run that cannot see the share, reporting
# 0/0/0 over the top of the real result from this one.
$stale = Get-ScheduledTask -TaskName $LegacyTask -ErrorAction SilentlyContinue
if ($stale) {
Unregister-ScheduledTask -TaskName $LegacyTask -Confirm:$false -ErrorAction SilentlyContinue
Write-ShadowLog "removed the superseded '$LegacyTask' task (it ran outside the share mount)."
}
# Scope is this script's own directory name, never hardcoded: the same file
# ships from more than one scope and a wrong value would shadow the wrong
# manifest silently.
$scopeDir = Split-Path -Parent $PSScriptRoot
$scope = Split-Path -Leaf $scopeDir
$shareManifest = Join-Path $scopeDir 'manifest.json'
$runner = Join-Path $InstallDir 'Invoke-ShopdbEnforce.ps1'
$engine = Join-Path $InstallDir 'lib\Install-FromManifest.ps1'
foreach ($required in @($runner, $engine, $shareManifest)) {
if (-not (Test-Path -LiteralPath $required)) {
Write-ShadowLog "MISSING $required - skipping this cycle."
exit 0
}
}
# THE BAY'S OWN VALUE WINS. This used to hold one site's ShopDB URL as a
# compiled-in constant and write it whenever the registry disagreed. At the
# site it was written for that reads as "heal drift"; anywhere else it reads
# as "overwrite this site's address on every enforce cycle", and the site
# cannot win because the cycle repeats. A second plant could not point its
# own bays at its own server.
#
# So: an explicitly passed -BaseUrl seeds the value, an existing registry
# value is never touched, and with neither there is nothing honest to write.
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
$current = (Get-ItemProperty -Path $regPath -Name BaseUrl -ErrorAction SilentlyContinue).BaseUrl
if (-not $current) {
if (-not $BaseUrl) {
Write-ShadowLog "no BaseUrl in $regPath and none passed - set it, or pass -BaseUrl in the manifest entry. Skipping this cycle."
exit 0
}
Set-ItemProperty -Path $regPath -Name BaseUrl -Value $BaseUrl
Write-ShadowLog "BaseUrl seeded as $BaseUrl"
} elseif ($BaseUrl -and $BaseUrl -ne $current) {
# Say it, do not do it. A changed address is a real event, and it should
# be a deliberate one, not a side effect of whatever the manifest last
# carried.
Write-ShadowLog "BaseUrl in registry is $current; -BaseUrl passed $BaseUrl. Keeping the registry value."
}
Write-ShadowLog "shadowing $scope against $shareManifest"
& $runner -Scope $scope -EnginePath $engine -ShadowMode -ShareManifestPath $shareManifest
Write-ShadowLog "cycle complete (runner exit $LASTEXITCODE)."
exit 0
}
catch {
Write-ShadowLog "FAILED: $_"
exit 0
}
finally {
# Clear it so the NEXT enforce cycle shadows again. Without this the guard
# would latch for the life of the process tree and shadow would run once.
$env:SHOPDB_SHADOW_ACTIVE = $null
}