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

@@ -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')