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:
|
||||
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