Publish gate: refuse a manifest the fleet's enforcer lib cannot read

This commit is contained in:
2026-08-12 15:26:27 -04:00
parent c0c2982810
commit ee7f8640d1
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():