Add GE-Enforce observed-state reporting: receipt + self-heal from PCs
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>
This commit is contained in:
@@ -19,26 +19,36 @@ from shopdb.api import (
|
||||
service_token_authorized,
|
||||
)
|
||||
|
||||
from ..models import ManifestScope, ManifestPublishedVersion
|
||||
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_fetch_token(f):
|
||||
"""Require a geenforce.fetch service token OR the env bootstrap key."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if service_token_authorized(FETCH_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
|
||||
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 ---------------------------------------------------
|
||||
@@ -78,6 +88,28 @@ def get_manifest():
|
||||
'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'])
|
||||
@@ -115,3 +147,92 @@ def preview_scope(scopeid):
|
||||
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],
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user