Publish gate: refuse a manifest the fleet's enforcer lib cannot read
This commit is contained in:
@@ -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