displays: clear a pending Edge update without waiting for 02:00

Every kiosk was sitting on Edge's restart-to-update prompt. The scope
already sets RelaunchNotification=2 so Edge restarts unattended, but
RelaunchWindow defers that restart to 02:00-04:00, so during the day the
update waits and there is nobody on site to dismiss the prompt.

A one-shot entry runs the Edge updater and stops the browser. It does not
relaunch it - the enforce task is SYSTEM in session 0, where a launched
browser is invisible - so it leans on the watchdog that already relaunches
the kiosk from the Startup shortcut. That relaunch is what applies the
staged update.

One-shot is DetectionMethod=MarkerFile. The engine writes the marker only
after a 0 exit, so a failed run retries next cycle instead of being
recorded as done, and the script exits 0 when no Edge was running - that
is a success, and failing it would withhold the marker and re-kill Edge on
every cycle from then on. The marker path carries a date, which is the
re-arm mechanism for a future update.

Ordered after the watchdog entry: a display seeing both for the first time
must have its relauncher registered before anything stops the browser.
This commit is contained in:
cproudlock
2026-08-12 12:26:29 -04:00
parent 457349d258
commit 787f475208
2 changed files with 164 additions and 8 deletions

View File

@@ -11,8 +11,9 @@ from plugins.geenforce.models import (
)
from plugins.geenforce.seed_display_scope import (
seed_display_scope, build_display_manifest, build_dispatcher_script,
build_watchdog_script,
build_watchdog_script, build_forceedgeupdate_script,
DISPLAY_TYPE_TARGETS, SCOPE_NAME, DISPATCHER_FILENAME, ALWAYSON_FILENAME,
FORCE_EDGE_UPDATE_FILENAME, FORCE_EDGE_UPDATE_MARKER,
)
@@ -26,11 +27,11 @@ def test_seed_creates_display_scope(db):
# and do NOT inherit common.
assert scope.iscommon is False
# Four Registry drift-heal entries + three inline PS1 (dispatcher,
# watchdog, always-on).
assert summary['entrycount'] == 7
# Four Registry drift-heal entries + four inline PS1 (dispatcher, watchdog,
# one-shot Edge force-update, always-on).
assert summary['entrycount'] == 8
assert summary['entrytypes'] == ['Registry', 'Registry', 'Registry',
'Registry', 'PS1', 'PS1', 'PS1']
'Registry', 'PS1', 'PS1', 'PS1', 'PS1']
def test_registry_entries_use_valuematches_detection(db):
@@ -115,15 +116,17 @@ def test_seed_draft_is_idempotent(db):
assert first['dispatchersha256'] == second['dispatchersha256']
assert first['alwaysonsha256'] == second['alwaysonsha256']
assert first['watchdogsha256'] == second['watchdogsha256']
assert first['forceupdatesha256'] == second['forceupdatesha256']
entries = ManifestEntry.query.filter_by(scopeid=second['scopeid']).all()
assert len(entries) == 7
# Exactly three inline payloads (dispatcher + watchdog + always-on) exist
assert len(entries) == 8
# Exactly four inline payloads (dispatcher + watchdog + force-update +
# always-on) exist
# after a rebuild, not more - a payload-count invariant. (The underlying
# re-publish FK crash only reproduces on MySQL, which enforces the
# manifestpayloads FK; it was verified there directly. SQLite does not
# enforce it.)
assert ManifestPayload.query.count() == 3
assert ManifestPayload.query.count() == 4
def test_build_manifest_has_no_smb_exe_payloads(db):
@@ -203,3 +206,58 @@ def test_watchdog_refuses_to_run_as_system():
so a mis-registered task would kill and relaunch the display every cycle."""
script = build_watchdog_script()
assert 'IsSystem' in script
def _forceupdate_entry():
return next(a for a in build_display_manifest()['Applications']
if a['Name'].startswith('Force pending Edge update'))
def test_forceupdate_is_a_one_shot_marker_entry(db):
"""MarkerFile is what makes it one-shot. With Always it would kill Edge on
every enforce cycle forever, which is a kiosk that never stays up."""
entry = _forceupdate_entry()
assert entry['Type'] == 'PS1'
assert entry['PayloadSource'] == 'inline'
assert entry['DetectionMethod'] == 'MarkerFile'
# The engine only writes the marker when DetectionPath is set; without it
# the entry silently degrades to running every cycle.
assert entry['DetectionPath'] == FORCE_EDGE_UPDATE_MARKER
def test_forceupdate_runs_after_the_watchdog_is_registered(db):
"""It stops Edge and relies on the watchdog to bring the kiosk back, so on a
display seeing both for the first time the watchdog must come first."""
names = [a['Name'] for a in build_display_manifest()['Applications']]
watchdog = names.index('Display kiosk watchdog (relaunch Edge)')
forceupdate = next(i for i, n in enumerate(names)
if n.startswith('Force pending Edge update'))
assert watchdog < forceupdate
def test_forceupdate_payload_is_stored(db):
"""An entry whose payload never stored would enforce nothing at all."""
summary = seed_display_scope()
payload = ManifestPayload.query.filter_by(
filename=FORCE_EDGE_UPDATE_FILENAME).one()
assert payload.payloadsha256 == summary['forceupdatesha256']
def test_forceupdate_script_exits_zero_so_the_marker_is_written():
"""The engine writes the marker only on a 0 exit. A script that failed on
'no Edge running' would never mark done and would re-kill Edge forever."""
script = build_forceedgeupdate_script()
assert script.rstrip().endswith('exit 0')
assert 'exit 1' not in script
def test_forceupdate_script_does_not_launch_the_browser_itself():
"""SYSTEM runs in session 0, where a launched browser is invisible. Relaunch
is the watchdog's job, from the Startup shortcut."""
script = build_forceedgeupdate_script()
assert 'msedge' in script
assert 'Start-Process' not in script.split('# Stop every Edge process')[1]
def test_forceupdate_script_is_ascii():
build_forceedgeupdate_script().encode('ascii')