"""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//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/', 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], })