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:
@@ -726,17 +726,49 @@ def publish_scope_route(scopeid):
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
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:
|
||||
publishedby = int(get_jwt_identity())
|
||||
except (TypeError, ValueError):
|
||||
publishedby = None
|
||||
version = service.publish_scope(scope.scopename, scope.phase,
|
||||
notes=notes, publishedby=publishedby)
|
||||
try:
|
||||
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()
|
||||
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'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
|
||||
@@ -218,13 +218,21 @@ class GeEnforcePlugin(BasePlugin):
|
||||
@click.argument('scopename')
|
||||
@click.option('--phase', default='runtime')
|
||||
@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."""
|
||||
from flask import current_app
|
||||
from .service import publish_scope
|
||||
from .service import publish_scope, LibVersionTooOldError
|
||||
|
||||
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()
|
||||
click.echo(f"Published {scopename}/{phase} as v{version}.")
|
||||
|
||||
|
||||
@@ -26,10 +26,77 @@ from .importer import build_entry
|
||||
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():
|
||||
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():
|
||||
"""Lazily import the computers plugin's ComputerInstalledApp.
|
||||
|
||||
@@ -72,14 +139,38 @@ def replace_scope_draft(scopename, phase, manifest):
|
||||
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
|
||||
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(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
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)
|
||||
maxversion = db.session.query(
|
||||
func.max(ManifestPublishedVersion.versionnumber)
|
||||
|
||||
Reference in New Issue
Block a user