From 6cdbea449a00d1875346630575c350ac164a6f92 Mon Sep 17 00:00:00 2001 From: cproudlock Date: Thu, 13 Aug 2026 11:21:28 -0400 Subject: [PATCH] geenforce: ship shadow mode through the share, and correct the cutover doc 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. --- docs/geenforce-api-cutover.md | 52 +++++++++--- .../client/Register-ShopdbShadow.ps1 | 79 +++++++++++++++++++ 2 files changed, 122 insertions(+), 9 deletions(-) create mode 100644 plugins/geenforce/client/Register-ShopdbShadow.ps1 diff --git a/docs/geenforce-api-cutover.md b/docs/geenforce-api-cutover.md index 75b6b07..1a657e4 100644 --- a/docs/geenforce-api-cutover.md +++ b/docs/geenforce-api-cutover.md @@ -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 `-InstallerRoot` to that same payloads directory, so the engine's -`Join-Path $InstallerRoot ` resolves to the staged file. `smb` entries in -a mixed manifest are left untouched and still resolve against the share (a PC -that has it). +`Join-Path $InstallerRoot ` resolves to the staged file. + +**`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. 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 - `smb`, nothing to upload, the engine resolves share paths as today). Only - a genuinely share-less PC needs http/inline payload conversion. Note the - payload endpoint's 512 MB default ceiling - (`GEENFORCE_PAYLOAD_MAX_BYTES`) before promising huge installers over - HTTPS. + the intended cheap cutover is manifest-over-API + payloads-still-smb + (entries stay `smb`, nothing to upload). Only a genuinely share-less PC + needs http/inline payload conversion. Note the payload endpoint's 512 MB + default ceiling (`GEENFORCE_PAYLOAD_MAX_BYTES`) before promising huge + installers over 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 + `, 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= 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: `flask geenforce parity` then `flask geenforce import-share --scope `. New/reworked scope: author in code following diff --git a/plugins/geenforce/client/Register-ShopdbShadow.ps1 b/plugins/geenforce/client/Register-ShopdbShadow.ps1 new file mode 100644 index 0000000..e970128 --- /dev/null +++ b/plugins/geenforce/client/Register-ShopdbShadow.ps1 @@ -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 :\\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 +}