displays: the client module updates itself

Install-ShopdbKiosk.ps1 lays the enforce client down once at bootstrap and
never refreshes it. So a client change rode the code deploy to the server
and then sat one directory away from where kiosks actually fetch, waiting
for someone to re-stage the installer bundle by hand - which is how the new
display-type reporting reached prod and changed nothing on any kiosk.

The module now ships as a manifest entry like everything else in this
scope: inline over HTTPS, Hash detection against the exact bytes shipped,
written to the same path the installer uses so bootstrap and self-update
cannot disagree. Ordered first, so a stale client refreshes before anything
leans on it. The installer keeps its real job - a fresh kiosk still needs
something that can talk to shopdb - it just stops being the update path.

Self-modifying by design: this module is what stages payloads, but
PowerShell loads it into memory at start, so rewriting the file mid-run is
harmless and lands on the next cycle. Pilot a client change on ONE kiosk
before the fleet: a broken module cannot fetch its own replacement, and on
a share-less display that means a site visit.
This commit is contained in:
cproudlock
2026-08-12 17:21:29 -04:00
parent 84bf5d04ed
commit 5de3594425
2 changed files with 108 additions and 12 deletions

View File

@@ -27,6 +27,9 @@ uses), then attach the inline dispatcher payload and optionally publish. Re-
running replace_scope_draft is an idempotent draft rebuild.
"""
import hashlib
import os
from shopdb.api import db
from . import service
@@ -58,10 +61,16 @@ DISPLAY_TYPE_TARGETS = {
DISPATCHER_FILENAME = 'Invoke-DisplayKioskDispatch.ps1'
# One-shot: apply a pending Edge update and bounce the kiosk browser.
# The marker path carries a DATE. That is the whole re-arm mechanism: change the
# date and every display runs it once more. Leave it alone and each display runs
# it exactly once, ever.
# The enforce client module itself, delivered as an enforced entry so a client
# change no longer means hands on every kiosk. Path matches where
# Install-ShopdbKiosk.ps1 puts it, so the installer stays the BOOTSTRAP and this
# becomes the update path.
CLIENT_MODULE_FILENAME = 'ShopdbEnforceClient.psm1'
CLIENT_MODULE_DEST = r'C:\ProgramData\GE-Enforce\ShopdbEnforceClient.psm1'
# One-shot: apply a pending Edge update and bounce the kiosk browser. The marker
# path carries a DATE. That is the whole re-arm mechanism: change the date and
# every display runs it once more; leave it alone and each runs it exactly once.
FORCE_EDGE_UPDATE_FILENAME = 'Invoke-EdgeForceUpdate.ps1'
FORCE_EDGE_UPDATE_MARKER = (
r'C:\ProgramData\ShopDB\markers\edge-force-update-2026-08-12.done')
@@ -584,6 +593,22 @@ Write-Host 'kiosk always-on enforced (power never-off + screensaver/lock disable
'''
def read_client_module():
"""The enforce client module's bytes, read from this repo.
Shipping it as a manifest entry closes a real gap: the module is installed
once by Install-ShopdbKiosk.ps1 and never refreshed, so a client change
reached the server with the code deploy and then sat one directory away from
where kiosks fetch from, waiting for someone to re-stage the installer
bundle by hand. Displays are share-less by design; hands on every kiosk is
the wrong cost for a client change.
"""
path = os.path.join(os.path.dirname(os.path.abspath(__file__)),
'client', CLIENT_MODULE_FILENAME)
with open(path, 'rb') as handle:
return handle.read()
def build_forceedgeupdate_script():
"""Return the inline one-shot Edge force-update PowerShell as text.
@@ -677,7 +702,34 @@ def build_display_manifest():
exist. Kept payload-free otherwise: the kiosk engine and browser are baked
into the display image, not shipped over HTTPS.
"""
clientbytes = read_client_module()
applications = [
{
'_comment': (
'The GE-Enforce client module itself. Install-ShopdbKiosk.ps1 '
'lays this down once at bootstrap and never refreshes it, so a '
'client change reached the server with the code deploy and then '
'sat one directory away from where kiosks fetch, waiting for '
'someone to re-stage the installer bundle by hand. Displays are '
'share-less by design; hands on every kiosk is the wrong cost '
'for a client change. Hash detection against the shipped bytes, '
'so a matching module is left alone and only a changed one is '
'rewritten. SELF-MODIFYING BY DESIGN: this module is what stages '
'payloads, but PowerShell loads it into memory at start, so '
'rewriting the file mid-run is harmless and takes effect on the '
'NEXT cycle. Pilot a client change on ONE kiosk before the fleet '
'- a broken module cannot fetch its own replacement, and on a '
'share-less display that means a site visit.'),
'Name': 'GE-Enforce client module (self-update)',
'Type': 'File',
'Source': CLIENT_MODULE_FILENAME,
'Destination': CLIENT_MODULE_DEST,
'PayloadSource': 'inline',
'PayloadRef': CLIENT_MODULE_FILENAME,
'DetectionMethod': 'Hash',
'DetectionPath': CLIENT_MODULE_DEST,
'DetectionValue': hashlib.sha256(clientbytes).hexdigest(),
},
_registry_drift_heal_entry(
'Edge kiosk RelaunchNotification (Required auto-restart)',
'RelaunchNotification', 2, 'DWord',
@@ -823,6 +875,13 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
watchdog, WATCHDOG_FILENAME,
'text/plain; charset=utf-8', watchdogbytes)
clientmodule = next(entry for entry in scope.entries
if entry.name.startswith('GE-Enforce client module'))
clientbytes = read_client_module()
clientpayload = service.store_inline_payload(
clientmodule, CLIENT_MODULE_FILENAME,
'text/plain; charset=utf-8', clientbytes)
forceupdate = next(entry for entry in scope.entries
if entry.name.startswith('Force pending Edge update'))
forceupdatebytes = build_forceedgeupdate_script().encode('utf-8')
@@ -845,5 +904,6 @@ def seed_display_scope(publish=False, notes='seed gea-shopfloor-display'):
'alwaysonsha256': alwaysonpayload.payloadsha256,
'watchdogsha256': watchdogpayload.payloadsha256,
'forceupdatesha256': forceupdatepayload.payloadsha256,
'clientmodulesha256': clientpayload.payloadsha256,
'publishedversion': publishedversion,
}