Publish gate: refuse a manifest the fleet's enforcer lib cannot read
This commit is contained in:
@@ -726,17 +726,49 @@ def publish_scope_route(scopeid):
|
|||||||
if not scope:
|
if not scope:
|
||||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||||
from flask_jwt_extended import get_jwt_identity
|
from flask_jwt_extended import get_jwt_identity
|
||||||
notes = (request.get_json(silent=True) or {}).get('notes')
|
body = request.get_json(silent=True) or {}
|
||||||
|
notes = body.get('notes')
|
||||||
try:
|
try:
|
||||||
publishedby = int(get_jwt_identity())
|
publishedby = int(get_jwt_identity())
|
||||||
except (TypeError, ValueError):
|
except (TypeError, ValueError):
|
||||||
publishedby = None
|
publishedby = None
|
||||||
version = service.publish_scope(scope.scopename, scope.phase,
|
try:
|
||||||
notes=notes, publishedby=publishedby)
|
version = service.publish_scope(
|
||||||
|
scope.scopename, scope.phase, notes=notes,
|
||||||
|
publishedby=publishedby, force=bool(body.get('force')))
|
||||||
|
except service.LibVersionTooOldError as error:
|
||||||
|
# 409, not 500: the request is well-formed, the fleet is not ready. The
|
||||||
|
# hosts come back so the caller can act without going hunting.
|
||||||
|
return error_response(
|
||||||
|
ErrorCodes.CONFLICT, str(error), http_code=409,
|
||||||
|
details={'required': error.required, 'floor': error.floor,
|
||||||
|
'hosts': error.hosts})
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
return success_response({'versionnumber': version}, http_code=201)
|
return success_response({'versionnumber': version}, http_code=201)
|
||||||
|
|
||||||
|
|
||||||
|
@geenforce_bp.route('/scopes/<int:scopeid>/publish-preflight', methods=['GET'])
|
||||||
|
@jwt_required()
|
||||||
|
@require_permission('geenforce.manage')
|
||||||
|
def publish_preflight(scopeid):
|
||||||
|
"""Would publishing this scope outrun the fleet's enforcer lib?
|
||||||
|
|
||||||
|
Lets the UI warn BEFORE someone clicks publish, rather than only refusing
|
||||||
|
afterwards.
|
||||||
|
"""
|
||||||
|
scope = db.session.get(ManifestScope, scopeid)
|
||||||
|
if not scope:
|
||||||
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||||
|
hosts, floor = service.hosts_below_libversion(
|
||||||
|
scope.scopename, scope.phase, scope.manifestversion)
|
||||||
|
return success_response({
|
||||||
|
'required': scope.manifestversion,
|
||||||
|
'floor': floor,
|
||||||
|
'hostsbehind': hosts,
|
||||||
|
'canpublish': not hosts,
|
||||||
|
})
|
||||||
|
|
||||||
|
|
||||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
||||||
@jwt_required()
|
@jwt_required()
|
||||||
@require_permission('geenforce.manage')
|
@require_permission('geenforce.manage')
|
||||||
|
|||||||
@@ -218,13 +218,21 @@ class GeEnforcePlugin(BasePlugin):
|
|||||||
@click.argument('scopename')
|
@click.argument('scopename')
|
||||||
@click.option('--phase', default='runtime')
|
@click.option('--phase', default='runtime')
|
||||||
@click.option('--notes', default=None)
|
@click.option('--notes', default=None)
|
||||||
def publish_cmd(scopename, phase, notes):
|
@click.option('--force', is_flag=True,
|
||||||
|
help='Publish even when reporting PCs run an older '
|
||||||
|
'enforcer lib than the manifest requires.')
|
||||||
|
def publish_cmd(scopename, phase, notes, force):
|
||||||
"""Freeze the current draft of a scope into a published snapshot."""
|
"""Freeze the current draft of a scope into a published snapshot."""
|
||||||
from flask import current_app
|
from flask import current_app
|
||||||
from .service import publish_scope
|
from .service import publish_scope, LibVersionTooOldError
|
||||||
|
|
||||||
with current_app.app_context():
|
with current_app.app_context():
|
||||||
version = publish_scope(scopename, phase, notes=notes)
|
try:
|
||||||
|
version = publish_scope(scopename, phase, notes=notes,
|
||||||
|
force=force)
|
||||||
|
except LibVersionTooOldError as error:
|
||||||
|
click.echo(click.style(f'REFUSED: {error}', fg='red'))
|
||||||
|
raise SystemExit(1)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
click.echo(f"Published {scopename}/{phase} as v{version}.")
|
click.echo(f"Published {scopename}/{phase} as v{version}.")
|
||||||
|
|
||||||
|
|||||||
@@ -26,10 +26,77 @@ from .importer import build_entry
|
|||||||
from .serializer import scope_to_json
|
from .serializer import scope_to_json
|
||||||
|
|
||||||
|
|
||||||
|
class LibVersionTooOldError(Exception):
|
||||||
|
"""Publishing this scope would reach PCs whose enforcer lib is too old.
|
||||||
|
|
||||||
|
Carries the detail a human needs to act: what the manifest requires, the
|
||||||
|
lowest lib version the fleet reports for this scope, and which hosts are
|
||||||
|
behind.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, scopename, required, floor, hosts):
|
||||||
|
self.scopename = scopename
|
||||||
|
self.required = required
|
||||||
|
self.floor = floor
|
||||||
|
self.hosts = hosts
|
||||||
|
shown = ', '.join(hosts[:10])
|
||||||
|
if len(hosts) > 10:
|
||||||
|
shown += f' (+{len(hosts) - 10} more)'
|
||||||
|
super().__init__(
|
||||||
|
f'{scopename} manifest is version {required} but {len(hosts)} '
|
||||||
|
f'reporting PC(s) run enforcer lib {floor}: {shown}. '
|
||||||
|
'Push the newer lib to the fleet first, or publish with force=True '
|
||||||
|
'if you are certain those PCs must not receive this scope.')
|
||||||
|
|
||||||
|
|
||||||
def _utcnow():
|
def _utcnow():
|
||||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||||
|
|
||||||
|
|
||||||
|
def parse_libversion(text):
|
||||||
|
"""'2.6' -> (2, 6). Returns None for missing or unparseable values.
|
||||||
|
|
||||||
|
None means "cannot tell", and every caller treats that as "do not claim
|
||||||
|
this PC is safe" rather than "this PC is fine" - an unreadable version is
|
||||||
|
exactly the case where guessing is expensive.
|
||||||
|
"""
|
||||||
|
if not text:
|
||||||
|
return None
|
||||||
|
parts = str(text).strip().split('.')
|
||||||
|
try:
|
||||||
|
return (int(parts[0]), int(parts[1]) if len(parts) > 1 else 0)
|
||||||
|
except (ValueError, IndexError):
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def hosts_below_libversion(scopename, phase, required):
|
||||||
|
"""Hosts whose CURRENT report for this scope runs a lib older than required.
|
||||||
|
|
||||||
|
A report with no/unparseable enforcerversion counts as behind: the field was
|
||||||
|
added with the summary-emitting engine, so its absence means an old lib.
|
||||||
|
|
||||||
|
Returns (hosts, floor) where floor is the lowest parseable version string
|
||||||
|
seen, or None when nothing reported.
|
||||||
|
"""
|
||||||
|
wanted = parse_libversion(required)
|
||||||
|
if not wanted:
|
||||||
|
return [], None
|
||||||
|
|
||||||
|
reports = ManifestEnforcementReport.query.filter_by(
|
||||||
|
scopename=scopename, phase=phase, iscurrent=True).all()
|
||||||
|
|
||||||
|
behind = []
|
||||||
|
lowest = None
|
||||||
|
for report in reports:
|
||||||
|
actual = parse_libversion(report.enforcerversion)
|
||||||
|
if actual is None or actual < wanted:
|
||||||
|
behind.append(report.hostname)
|
||||||
|
if actual is not None and (lowest is None or actual < lowest):
|
||||||
|
lowest = actual
|
||||||
|
floor = f'{lowest[0]}.{lowest[1]}' if lowest else None
|
||||||
|
return sorted(set(behind)), floor
|
||||||
|
|
||||||
|
|
||||||
def _installed_app_model():
|
def _installed_app_model():
|
||||||
"""Lazily import the computers plugin's ComputerInstalledApp.
|
"""Lazily import the computers plugin's ComputerInstalledApp.
|
||||||
|
|
||||||
@@ -72,14 +139,38 @@ def replace_scope_draft(scopename, phase, manifest):
|
|||||||
return scope
|
return scope
|
||||||
|
|
||||||
|
|
||||||
def publish_scope(scopename, phase, notes=None, publishedby=None):
|
def publish_scope(scopename, phase, notes=None, publishedby=None, force=False):
|
||||||
"""Freeze the current draft into a new published snapshot. Returns the
|
"""Freeze the current draft into a new published snapshot. Returns the
|
||||||
version number (uncommitted)."""
|
version number (uncommitted).
|
||||||
|
|
||||||
|
Refuses when the manifest is newer than the enforcer lib on PCs that report
|
||||||
|
for this scope, unless force=True.
|
||||||
|
|
||||||
|
WHY THIS GATE EXISTS: the lib treats a minor-newer manifest as backward
|
||||||
|
compatible and carries on. That holds for additions that WIDEN behaviour (a
|
||||||
|
new Type is skipped by an old lib), and inverts for one that NARROWS it. The
|
||||||
|
_CmmVersion filter arrived in lib 2.6 as a minor bump: an older lib does not
|
||||||
|
know the field, so every gated entry looks unfiltered and it 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. This is, and it
|
||||||
|
reads the version the fleet actually reports rather than the one someone
|
||||||
|
believes is deployed.
|
||||||
|
|
||||||
|
A scope nobody has reported for yet cannot be checked, so it publishes: a
|
||||||
|
fresh site would otherwise be unable to publish anything at all.
|
||||||
|
"""
|
||||||
scope = ManifestScope.query.filter_by(
|
scope = ManifestScope.query.filter_by(
|
||||||
scopename=scopename, phase=phase).first()
|
scopename=scopename, phase=phase).first()
|
||||||
if not scope:
|
if not scope:
|
||||||
raise ValueError(f'No scope {scopename}/{phase}')
|
raise ValueError(f'No scope {scopename}/{phase}')
|
||||||
|
|
||||||
|
if not force:
|
||||||
|
behind, floor = hosts_below_libversion(
|
||||||
|
scopename, phase, scope.manifestversion)
|
||||||
|
if behind:
|
||||||
|
raise LibVersionTooOldError(
|
||||||
|
scopename, scope.manifestversion, floor or 'unknown', behind)
|
||||||
|
|
||||||
text = scope_to_json(scope)
|
text = scope_to_json(scope)
|
||||||
maxversion = db.session.query(
|
maxversion = db.session.query(
|
||||||
func.max(ManifestPublishedVersion.versionnumber)
|
func.max(ManifestPublishedVersion.versionnumber)
|
||||||
|
|||||||
153
tests/test_plugins/test_geenforce_publish_gate.py
Normal file
153
tests/test_plugins/test_geenforce_publish_gate.py
Normal 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'
|
||||||
@@ -79,9 +79,13 @@ def test_received_latest_flips_when_new_version_published(client, db, app,
|
|||||||
auth_headers):
|
auth_headers):
|
||||||
_seed_and_publish(app)
|
_seed_and_publish(app)
|
||||||
secret = _token(client, auth_headers, ['geenforce.report'])
|
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',
|
client.post('/api/geenforce/report',
|
||||||
json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm',
|
json={'hostname': 'WJCMM02', 'scopename': 'gea-shopfloor-cmm',
|
||||||
'appliedversion': 1, 'counts': {}},
|
'appliedversion': 1, 'counts': {},
|
||||||
|
'enforcerversion': '2.6'},
|
||||||
headers={'X-API-Key': secret})
|
headers={'X-API-Key': secret})
|
||||||
# Publish a newer version; the PC is now behind.
|
# Publish a newer version; the PC is now behind.
|
||||||
with app.app_context():
|
with app.app_context():
|
||||||
|
|||||||
Reference in New Issue
Block a user