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

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

View File

@@ -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}.")

View File

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