geenforce: refuse to publish a manifest the fleet's lib cannot read

The engine treats a minor-newer manifest as backward compatible and carries
on. That holds for additions which WIDEN behaviour - an old lib skips a Type
it does not know - and inverts for one that NARROWS it. _CmmVersion arrived
in lib 2.6 as a minor bump, so a PC on 2.5 does not recognise the field,
reads every gated entry as unfiltered, and installs every PC-DMIS version it
cannot detect, on every CMM, within one cycle.

The share runbook already says push the lib first. A runbook is not a
control, and the failure is silent, fleet-wide and about five minutes fast.

ShopDB already had the evidence and was not using it: every enforcement
report carries the enforcer version, and publish_scope had no gate at all.
It now compares the scope's manifest version against the versions PCs
actually report for that scope and refuses when any is behind, naming the
hosts. force=True for someone who knows why. A report with no or an
unreadable version counts as behind - that field arrived with the
summary-emitting engine, so its absence IS an old lib, and treating unknown
as safe is precisely how this fails open.

A scope nobody has reported for still publishes, or a fresh site could
never publish anything. Versions compare numerically, since as text '2.10'
sorts below '2.9'.

Also exposed as a preflight endpoint so the UI can warn before someone
clicks publish, and as a 409 with the offending hosts rather than a 500.
This commit is contained in:
cproudlock
2026-08-12 15:23:57 -04:00
parent 787f475208
commit 9e34fafce5
5 changed files with 297 additions and 9 deletions

View File

@@ -0,0 +1,153 @@
"""Publishing must not outrun the enforcer lib deployed on the fleet.
THE FAILURE THIS PREVENTS: the engine treats a minor-newer manifest as backward
compatible and carries on. That is true for additions that WIDEN behaviour (an
old lib skips a Type it does not know) and false for one that NARROWS it. The
_CmmVersion filter shipped in lib 2.6 as a minor bump, so a PC on lib 2.5 does
not recognise the field, reads every gated entry as unfiltered, and installs
EVERY PC-DMIS version it cannot detect - on every CMM, within one cycle.
The share runbook says "push the lib first". A runbook is not a control.
"""
from datetime import datetime, timezone
import pytest
from plugins.geenforce import service
from plugins.geenforce.models import ManifestScope, ManifestEnforcementReport
from shopdb.api import db
def _scope(manifestversion='2.6', scopename='gea-shopfloor-cmm'):
scope = ManifestScope(scopename=scopename, phase='runtime',
manifestversion=manifestversion)
db.session.add(scope)
db.session.flush()
return scope
def _report(hostname, enforcerversion, scopename='gea-shopfloor-cmm'):
report = ManifestEnforcementReport(
hostname=hostname, scopename=scopename, phase='runtime',
enforcerversion=enforcerversion, receivedat=datetime.now(timezone.utc).replace(tzinfo=None),
iscurrent=True)
db.session.add(report)
db.session.flush()
return report
# -- version parsing ---------------------------------------------------------
@pytest.mark.parametrize('text,expected', [
('2.6', (2, 6)),
('2.10', (2, 10)), # 10 > 6, not string-ordered
('3', (3, 0)),
(' 2.6 ', (2, 6)),
(None, None),
('', None),
('unknown', None),
('2.x', None),
])
def test_parse_libversion(text, expected):
assert service.parse_libversion(text) == expected
def test_minor_versions_compare_numerically_not_as_strings():
"""'2.10' vs '2.9' is the classic string-compare trap: as text '2.10' sorts
BELOW '2.9' and a current lib would look out of date."""
assert service.parse_libversion('2.10') > service.parse_libversion('2.9')
# -- the gate ----------------------------------------------------------------
def test_publish_refused_when_a_cmm_runs_an_older_lib(db):
"""The real scenario: cmm manifest 2.6, one bay still on 2.5."""
_scope('2.6')
_report('CMMBAY01', '2.6')
_report('CMMBAY02', '2.5')
with pytest.raises(service.LibVersionTooOldError) as caught:
service.publish_scope('gea-shopfloor-cmm', 'runtime')
error = caught.value
assert error.hosts == ['CMMBAY02']
assert error.required == '2.6'
assert 'CMMBAY02' in str(error)
# The message has to say what to do, not just that it said no.
assert 'lib' in str(error).lower()
def test_publish_allowed_when_the_whole_fleet_is_current(db):
_scope('2.6')
_report('CMMBAY01', '2.6')
_report('CMMBAY02', '2.7')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_a_missing_enforcerversion_counts_as_behind(db):
"""The field arrived with the summary-emitting engine, so its absence means
an old lib. Treating unknown as safe is how this fails open."""
_scope('2.6')
_report('CMMBAY01', None)
with pytest.raises(service.LibVersionTooOldError) as caught:
service.publish_scope('gea-shopfloor-cmm', 'runtime')
assert caught.value.hosts == ['CMMBAY01']
def test_an_unparseable_enforcerversion_counts_as_behind(db):
_scope('2.6')
_report('CMMBAY01', 'garbage')
with pytest.raises(service.LibVersionTooOldError):
service.publish_scope('gea-shopfloor-cmm', 'runtime')
def test_force_publishes_anyway(db):
"""The gate is a guard rail, not a wall - someone who knows why can pass."""
_scope('2.6')
_report('CMMBAY02', '2.5')
assert service.publish_scope(
'gea-shopfloor-cmm', 'runtime', force=True) == 1
def test_a_scope_nobody_reports_for_still_publishes(db):
"""No reports = no floor to compare. A fresh site must not be unable to
publish anything at all."""
_scope('2.6')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_other_scopes_reports_do_not_block_this_one(db):
"""A PC on an old lib reporting for 'common' says nothing about whether the
cmm scope may publish - only PCs that receive THIS scope matter."""
_scope('2.6')
_report('OLDPC', '2.0', scopename='common')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_superseded_reports_are_ignored(db):
"""Only the current report per host counts; a stale row from before a lib
upgrade must not block forever."""
_scope('2.6')
old = _report('CMMBAY01', '2.5')
old.iscurrent = False
_report('CMMBAY01', '2.6')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_an_unversioned_manifest_does_not_block(db):
"""A scope with no parseable manifestversion has nothing to require."""
_scope('legacy')
_report('CMMBAY01', '2.5')
assert service.publish_scope('gea-shopfloor-cmm', 'runtime') == 1
def test_hosts_below_libversion_reports_the_floor(db):
_scope('2.6')
_report('A', '2.5')
_report('B', '2.3')
_report('C', '2.6')
hosts, floor = service.hosts_below_libversion(
'gea-shopfloor-cmm', 'runtime', '2.6')
assert hosts == ['A', 'B']
assert floor == '2.3'

View File

@@ -79,9 +79,13 @@ def test_received_latest_flips_when_new_version_published(client, db, app,
auth_headers):
_seed_and_publish(app)
secret = _token(client, auth_headers, ['geenforce.report'])
# enforcerversion matters here: the publish gate refuses to ship a 2.6
# manifest to a PC that cannot prove it runs at least lib 2.6, and a report
# with no version is treated as an old lib.
client.post('/api/geenforce/report',
json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm',
'appliedversion': 1, 'counts': {}},
'appliedversion': 1, 'counts': {},
'enforcerversion': '2.6'},
headers={'X-API-Key': secret})
# Publish a newer version; the PC is now behind.
with app.app_context():