geenforce: ship shadow mode through the share, and correct the cutover doc
Some checks failed
CI / backend (push) Failing after 7s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 9s
CI / migrations-mysql (push) Failing after 7s

The cheap cutover the doc described - manifest over API, payloads still on the
share - does not work. Measured on the win11 VM against lib 2.6 with the same
manifest and engine, varying only InstallerRoot: the payload cache gives
0 installed / 2 failed ("CMD/BAT not found" under the cache), the scope
directory gives 2 installed / 0 failed.

Two causes. Invoke-ShopdbEnforce.ps1 takes no -InstallerRoot and hardcodes the
payload cache outside shadow mode, which is right for displays where every
entry is inline and wrong for every smb entry. And -IncludeCommon cannot work
for smb payloads at all: they live under their own scope directory, so a merged
manifest needs two roots where the engine takes one, and Join-InstallerPath
rejects rooted values by design so absolute paths are not a way out. The real
dispatcher never merges - it calls the engine once per scope with that scope's
root - and a fix should follow that shape.

Both claims are corrected in the doc rather than deleted, because reading it
was what made this look like a twenty-minute job; it would have failed 29 of 31
entries on the pilot bay.

Shadow mode is unaffected - it points InstallerRoot at the share scope dir,
which is the working path - so the pilot proceeds. Register-ShopdbShadow.ps1
is the reference copy of the script that puts a share-attached bay into shadow:
it writes BaseUrl, then registers the task as SYSTEM. It derives the share
manifest from $PSScriptRoot rather than hardcoding a drive, because GE-Enforce
mounts the share to a letter it picks at run time and SYSTEM has no standing
UNC access. Delivered as a manifest entry gated to one hostname, so the pilot
needs no hands on the bay and heals if it drifts.
This commit is contained in:
cproudlock
2026-08-13 11:21:28 -04:00
parent 7d66551622
commit 6cdbea449a
2 changed files with 122 additions and 9 deletions

View File

@@ -195,9 +195,16 @@ is the bridge that lets the UNCHANGED engine install share-less:
The runner (`Invoke-ShopdbEnforce.ps1`) then sets the engine's The runner (`Invoke-ShopdbEnforce.ps1`) then sets the engine's
`-InstallerRoot` to that same payloads directory, so the engine's `-InstallerRoot` to that same payloads directory, so the engine's
`Join-Path $InstallerRoot <leaf>` resolves to the staged file. `smb` entries in `Join-Path $InstallerRoot <leaf>` resolves to the staged file.
a mixed manifest are left untouched and still resolve against the share (a PC
that has it). **`smb` entries in a mixed manifest DO NOT survive this.** An earlier version of
this doc claimed they were "left untouched and still resolve against the share";
that is false outside shadow mode and was measured wrong on the VM (see the
blockers in section 11 step 1). `Resolve-ShopdbPayloads` does leave them
untouched, but the runner has already pointed `-InstallerRoot` at the payload
cache, so a share-relative path resolves under the cache and is reported
`not found`. A manifest that mixes `smb` with http/inline cannot be served by a
single `InstallerRoot` at all.
--- ---
@@ -672,12 +679,39 @@ Checklist for cutting any of the remaining scopes (`gea-shopfloor-cmm`,
`-nocollections`, `common`) over to the API. `-nocollections`, `common`) over to the API.
1. **Decide the delivery model.** Does this pc-type keep SMB access? If yes, 1. **Decide the delivery model.** Does this pc-type keep SMB access? If yes,
the cheap cutover is manifest-over-API + payloads-still-smb (entries stay the intended cheap cutover is manifest-over-API + payloads-still-smb
`smb`, nothing to upload, the engine resolves share paths as today). Only (entries stay `smb`, nothing to upload). Only a genuinely share-less PC
a genuinely share-less PC needs http/inline payload conversion. Note the needs http/inline payload conversion. Note the payload endpoint's 512 MB
payload endpoint's 512 MB default ceiling default ceiling (`GEENFORCE_PAYLOAD_MAX_BYTES`) before promising huge
(`GEENFORCE_PAYLOAD_MAX_BYTES`) before promising huge installers over installers over HTTPS.
HTTPS.
**THE CHEAP CUTOVER DOES NOT WORK WITH THE CURRENT RUNNER. Verified on the
win11 VM against lib 2.6, 2026-08-13.** Two blockers, both in delivery, not
in the manifests:
- `Invoke-ShopdbEnforce.ps1` has no `-InstallerRoot` parameter. Outside
shadow mode it hardcodes the payloads cache
(`Join-Path (Split-Path -Parent $manifestToRun) 'payloads'`). That is
right for displays, where every entry is inline, and wrong for every
`smb` entry: the engine resolves `Join-InstallerPath $InstallerRoot
<field>`, so `scripts/Install-AcroReader.cmd` is looked for under the
payload cache and logs `CMD/BAT not found`. Measured on the VM with the
same manifest and engine: InstallerRoot=payloads gave 0 installed /
2 failed, InstallerRoot=<scope dir> gave 2 installed / 0 failed.
- `-IncludeCommon` cannot work for `smb` payloads at all. Payloads live
under their own scope directory (`common/scripts/...` vs
`gea-shopfloor-nocollections/scripts/...`), so a merged manifest needs
two roots and the engine takes one. `Join-InstallerPath` also rejects
rooted values outright, so absolute paths are not a workaround - that
rejection is deliberate, to stop an entry escaping its share root.
This is why the real dispatcher does NOT merge: `common/GE-Enforce.ps1`
builds a target list and calls the engine ONCE PER SCOPE with
`-InstallerRoot $t.Root`. Any fix should follow that shape.
Until it is fixed, a share-attached cohort can still run SHADOW mode
(below), which is unaffected: shadow sets InstallerRoot to the share scope
dir, which is the working path.
2. **Get the scope into shopdb.** Existing share manifest: 2. **Get the scope into shopdb.** Existing share manifest:
`flask geenforce parity` then `flask geenforce import-share --scope `flask geenforce parity` then `flask geenforce import-share --scope
<name>`. New/reworked scope: author in code following <name>`. New/reworked scope: author in code following

View File

@@ -0,0 +1,79 @@
# Register-ShopdbShadow.ps1 -- put this bay into shopdb SHADOW mode.
#
# Shadow mode = fetch the shopdb manifest, diff it against the share manifest,
# report the cycle to shopdb, and install FROM THE SHARE exactly as today. Zero
# behaviour change. It is the observable step before any cutover.
#
# Runs as SYSTEM under GE-Enforce, from a manifest entry gated to one hostname.
# Idempotent: re-registers the task each cycle so drift self-heals, and writes
# BaseUrl only when it differs.
#
# WHY the share manifest is read through the mounted drive: GE-Enforce.ps1
# mounts the SFLD share with SFLD credentials before invoking the engine, and
# this script runs inside that window. SYSTEM has no standing access to the UNC
# path, so the mounted drive is the only path that resolves. The drive letter is
# not fixed, so it is derived from where this script is running rather than
# hardcoded.
$ErrorActionPreference = 'Continue'
$TaskName = 'ShopDB GE-Enforce (shadow)'
$InstallDir = 'C:\Program Files\GE\Shopfloor'
$BaseUrl = 'https://tsgwp00525.wjs.geaerospace.net/shopdb'
$Scope = 'gea-shopfloor-nocollections'
function Write-ShadowLog {
param([string]$Message)
$line = "[{0}] [shadow-setup] {1}" -f (Get-Date -Format 'yyyy-MM-dd HH:mm:ss'), $Message
Write-Host $line
}
try {
# --- BaseUrl (the client reads this; no token needed on an allowlisted subnet)
$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 ($current -ne $BaseUrl) {
Set-ItemProperty -Path $regPath -Name BaseUrl -Value $BaseUrl
Write-ShadowLog "BaseUrl set to $BaseUrl"
}
$runner = Join-Path $InstallDir 'Invoke-ShopdbEnforce.ps1'
$engine = Join-Path $InstallDir 'lib\Install-FromManifest.ps1'
foreach ($p in @($runner, $engine)) {
if (-not (Test-Path -LiteralPath $p)) {
Write-ShadowLog "MISSING $p - the File entries have not landed yet; will retry next cycle."
exit 0 # fail-safe: never break the bay, the entry re-runs
}
}
# --- the share manifest this scope is enforced from, via the mounted drive.
# $PSScriptRoot is <drive>:\<scope>\shopdb-client, so its grandparent is the
# scope dir. Deriving it keeps this correct whatever letter GE-Enforce mounted.
$scopeDir = Split-Path -Parent $PSScriptRoot
$shareManifest = Join-Path $scopeDir 'manifest.json'
if (-not (Test-Path -LiteralPath $shareManifest)) {
Write-ShadowLog "share manifest not found at $shareManifest - not registering."
exit 0
}
$arguments = '-NoProfile -ExecutionPolicy Bypass -File "{0}" -Scope "{1}" -EnginePath "{2}" -ShadowMode -ShareManifestPath "{3}"' `
-f $runner, $Scope, $engine, $shareManifest
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
# RepetitionInterval ALONE - passing RepetitionDuration serializes to a
# Duration the Task Scheduler schema rejects.
$trigger = New-ScheduledTaskTrigger -Once -At (Get-Date) -RepetitionInterval (New-TimeSpan -Minutes 15)
$principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries -DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $trigger `
-Principal $principal -Settings $settings -Force | Out-Null
Write-ShadowLog "registered '$TaskName' (every 15 min), shadowing $shareManifest"
exit 0
}
catch {
# Fail-safe: a broken setup script must never stop the bay enforcing.
Write-ShadowLog "FAILED: $_"
exit 0
}