PCs now report enforcement results back to shopdb, closing the desired-vs-observed loop. - POST /api/geenforce/report (geenforce.report service token): each cycle a PC posts the published version it applied, install/skip/fail/filtered counts, and per-entry outcomes. - Two tables: manifestenforcementreports (latest-per-host + history: applied version, enforcer version, counts, derived status ok/selfhealed/failed) and manifestenforcementresults (per entry: action installed/skipped/failed, selfhealed flag, exit code, warning/error message). - RECEIVED: reports carry the applied version; the admin view derives receivedlatest by comparing it to the scope's current published version, so the fleet view shows which PCs picked up an update. - SELF-HEAL: per-entry action captures drift correction (installed when it should already be present) vs skipped (already good) vs failed, with messages. - Admin reads: GET /reports (fleet compliance rollup) and GET /reports/<id> (per-entry detail). New geenforce.report permission. - Tables added to the (undeployed) 0001 baseline; geenforce.post_report is a service-token endpoint so it is exempt from the JWT authz sweep, like the collector blueprint. 8 reporting tests; full suite green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
239 lines
9.2 KiB
Python
239 lines
9.2 KiB
Python
"""GE-Enforce plugin API.
|
|
|
|
Two audiences:
|
|
- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest.
|
|
Full CRUD + publish lands in P2; this is the P1/first-slice read surface.
|
|
- Client (service token, geenforce.fetch scope): GET /manifest serves the
|
|
CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the
|
|
collector's managed-token pattern (X-API-Key or Bearer PAT), plus an optional
|
|
GEENFORCE_API_KEY env bootstrap.
|
|
"""
|
|
|
|
from functools import wraps
|
|
|
|
from flask import Blueprint, request, current_app, Response
|
|
from flask_jwt_extended import jwt_required
|
|
|
|
from shopdb.api import (
|
|
db, success_response, error_response, ErrorCodes, require_permission,
|
|
service_token_authorized,
|
|
)
|
|
|
|
from ..models import (
|
|
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
|
)
|
|
from ..serializer import scope_to_manifest
|
|
from .. import service
|
|
|
|
geenforce_bp = Blueprint('geenforce', __name__)
|
|
|
|
FETCH_SCOPE = 'geenforce.fetch'
|
|
REPORT_SCOPE = 'geenforce.report'
|
|
|
|
|
|
def _require_service_token(scope):
|
|
"""Decorator factory: require `scope` service token OR the env bootstrap key."""
|
|
def wrapper(f):
|
|
@wraps(f)
|
|
def decorated(*args, **kwargs):
|
|
if service_token_authorized(scope):
|
|
return f(*args, **kwargs)
|
|
expected = current_app.config.get('GEENFORCE_API_KEY')
|
|
if expected and request.headers.get('X-API-Key') == expected:
|
|
return f(*args, **kwargs)
|
|
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
|
http_code=401)
|
|
return decorated
|
|
return wrapper
|
|
|
|
|
|
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
|
require_report_token = _require_service_token(REPORT_SCOPE)
|
|
|
|
|
|
# -- client-facing endpoint ---------------------------------------------------
|
|
|
|
@geenforce_bp.route('/manifest', methods=['GET'])
|
|
@require_fetch_token
|
|
def get_manifest():
|
|
"""Serve the current published manifest for a scope (fat-client: full scope).
|
|
|
|
Query: pctype (=scopename, required), phase (default runtime). The engine
|
|
filters client-side, matching today, so subtype/hostname/machinenumber/
|
|
cmmversion are accepted but not applied here.
|
|
"""
|
|
scopename = (request.args.get('pctype') or '').strip()
|
|
phase = (request.args.get('phase') or 'runtime').strip()
|
|
if not scopename:
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'pctype is required', http_code=400)
|
|
|
|
scope = ManifestScope.query.filter_by(
|
|
scopename=scopename, phase=phase).first()
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'No scope: {scopename}', http_code=404)
|
|
|
|
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
if not published:
|
|
return error_response(ErrorCodes.NOT_FOUND,
|
|
f'{scopename} has no published version',
|
|
http_code=404)
|
|
|
|
etag = f'"{scope.scopeid}-v{published.versionnumber}"'
|
|
if request.headers.get('If-None-Match') == etag:
|
|
return Response(status=304, headers={'ETag': etag})
|
|
return Response(published.manifestjson, mimetype='application/json',
|
|
headers={'ETag': etag,
|
|
'X-Manifest-Version': str(published.versionnumber)})
|
|
|
|
|
|
# -- client reporting (observed state) ----------------------------------------
|
|
|
|
@geenforce_bp.route('/report', methods=['POST'])
|
|
@require_report_token
|
|
def post_report():
|
|
"""Record one PC's enforcement cycle: applied version (did it receive the
|
|
update?) + per-entry self-heal outcomes (installed/skipped/failed)."""
|
|
payload = request.get_json(silent=True) or {}
|
|
if not (payload.get('hostname') or '').strip():
|
|
return error_response(ErrorCodes.VALIDATION_ERROR,
|
|
'hostname is required', http_code=400)
|
|
try:
|
|
report = service.record_enforcement_report(payload)
|
|
db.session.commit()
|
|
except ValueError as exc:
|
|
db.session.rollback()
|
|
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc),
|
|
http_code=400)
|
|
return success_response({'reportid': report.reportid,
|
|
'status': report.status}, message='recorded')
|
|
|
|
|
|
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
|
|
|
@geenforce_bp.route('/scopes', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def list_scopes():
|
|
"""List imaging PC-type scopes with entry + published-version counts."""
|
|
scopes = ManifestScope.query.order_by(
|
|
ManifestScope.phase, ManifestScope.scopename).all()
|
|
data = []
|
|
for scope in scopes:
|
|
current = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
data.append({
|
|
'scopeid': scope.scopeid,
|
|
'scopename': scope.scopename,
|
|
'phase': scope.phase,
|
|
'manifestversion': scope.manifestversion,
|
|
'computertypeid': scope.computertypeid,
|
|
'measuringtooltypeid': scope.measuringtooltypeid,
|
|
'iscommon': scope.iscommon,
|
|
'entrycount': len(scope.entries),
|
|
'publishedversion': current.versionnumber if current else None,
|
|
})
|
|
return success_response(data)
|
|
|
|
|
|
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def preview_scope(scopeid):
|
|
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
|
|
scope = db.session.get(ManifestScope, scopeid)
|
|
if not scope:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
|
http_code=404)
|
|
return success_response({'scopename': scope.scopename,
|
|
'manifest': scope_to_manifest(scope)})
|
|
|
|
|
|
def _current_published_version(scopename, phase):
|
|
scope = ManifestScope.query.filter_by(
|
|
scopename=scopename, phase=phase).first()
|
|
if not scope:
|
|
return None
|
|
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
|
return published.versionnumber if published else None
|
|
|
|
|
|
@geenforce_bp.route('/reports', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def list_reports():
|
|
"""Latest enforcement report per PC (fleet compliance view).
|
|
|
|
Each row shows the applied vs latest published version (receivedlatest = the
|
|
PC picked up the update) and the install/skip/fail counts + status.
|
|
Optional filters: hostname, scopename.
|
|
"""
|
|
query = ManifestEnforcementReport.query.filter_by(iscurrent=True)
|
|
hostname = request.args.get('hostname')
|
|
scopename = request.args.get('scopename')
|
|
if hostname:
|
|
query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname))
|
|
if scopename:
|
|
query = query.filter_by(scopename=scopename)
|
|
reports = query.order_by(
|
|
ManifestEnforcementReport.receivedat.desc()).all()
|
|
|
|
latest_cache = {}
|
|
data = []
|
|
for report in reports:
|
|
key = (report.scopename, report.phase)
|
|
if key not in latest_cache:
|
|
latest_cache[key] = _current_published_version(*key)
|
|
latest = latest_cache[key]
|
|
data.append({
|
|
'reportid': report.reportid,
|
|
'hostname': report.hostname,
|
|
'scopename': report.scopename,
|
|
'phase': report.phase,
|
|
'appliedversion': report.appliedversion,
|
|
'latestversion': latest,
|
|
'receivedlatest': (latest is not None
|
|
and report.appliedversion == latest),
|
|
'enforcerversion': report.enforcerversion,
|
|
'status': report.status,
|
|
'installed': report.installedcount,
|
|
'skipped': report.skippedcount,
|
|
'failed': report.failedcount,
|
|
'filtered': report.filteredcount,
|
|
'lastcheckin': (report.lastcheckin.isoformat() + 'Z'
|
|
if report.lastcheckin else None),
|
|
'receivedat': (report.receivedat.isoformat() + 'Z'
|
|
if report.receivedat else None),
|
|
})
|
|
return success_response(data)
|
|
|
|
|
|
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('geenforce.manage')
|
|
def get_report(reportid):
|
|
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
|
report = db.session.get(ManifestEnforcementReport, reportid)
|
|
if not report:
|
|
return error_response(ErrorCodes.NOT_FOUND, 'No such report',
|
|
http_code=404)
|
|
latest = _current_published_version(report.scopename, report.phase)
|
|
return success_response({
|
|
'reportid': report.reportid,
|
|
'hostname': report.hostname,
|
|
'scopename': report.scopename,
|
|
'phase': report.phase,
|
|
'appliedversion': report.appliedversion,
|
|
'latestversion': latest,
|
|
'receivedlatest': (latest is not None
|
|
and report.appliedversion == latest),
|
|
'status': report.status,
|
|
'results': [{
|
|
'entryname': r.entryname,
|
|
'action': r.action,
|
|
'selfhealed': r.selfhealed,
|
|
'exitcode': r.exitcode,
|
|
'message': r.message,
|
|
} for r in report.results],
|
|
})
|