Files
shopdb-flask/plugins/geenforce/api/routes.py
cproudlock b00ef72581 geenforce: HTTPS payload delivery (content-addressed blob store + endpoint)
Lets share-less (Intune/local-account) PCs pull installers the manifest
references over HTTPS instead of SMB - the general capability the whole fleet
migrates toward. New ManifestBlob registry (migration 0002) with bytes on disk
at instance/geenforce/payloads/<sha256> (deduped by content); service.store_blob
+ blob_path; client-facing GET /api/geenforce/payload/<sha256> (geenforce.fetch
token, ETag=hash, serves the blob store or an inline DB payload by hash). The
serializer now emits PayloadSource/PayloadSha256/PayloadRef for http/inline
entries only (smb entries round-trip unchanged - parity green). CLI
'flask geenforce add-payload <file>' registers a blob and prints its sha256.
This is the shopdb half (B1); the PS client/engine fetch is B2.
2026-07-21 10:10:59 -04:00

756 lines
30 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).
"""
import os
from functools import wraps
from flask import Blueprint, request, Response, send_file
from flask_jwt_extended import jwt_required
from sqlalchemy.exc import IntegrityError
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
service_token_authorized, Setting, Application,
)
SHAREROOT_SETTING = 'geenforce_share_root'
from ..models import (
ManifestScope, ManifestEntry, ManifestPublishedVersion,
ManifestEnforcementReport, ManifestPayload, ManifestBlob, ENTRY_TYPES, PHASES,
)
from ..serializer import scope_to_manifest, entry_to_dict
from ..importer import build_entry, populate_entry
from ..filters import (
entry_applies, matches_pctype, matches_hostname, matches_machinenumber,
matches_cmmversion,
)
from .. import service
geenforce_bp = Blueprint('geenforce', __name__)
FETCH_SCOPE = 'geenforce.fetch'
REPORT_SCOPE = 'geenforce.report'
def _require_service_token(scope):
"""Decorator factory: require a managed service token scoped for `scope`.
Tokens are the only client auth path (the client kit provisions a
geenforce.fetch/report token). No env-key fallback: it was never wired into
config and an unscoped shared key is a needless backdoor.
"""
def wrapper(f):
@wraps(f)
def decorated(*args, **kwargs):
if service_token_authorized(scope):
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 payload download (share-less installer delivery) ------------------
@geenforce_bp.route('/payload/<sha256>', methods=['GET'])
@require_fetch_token
def get_payload(sha256):
"""Serve a payload blob by content hash over HTTPS.
Lets share-less (Intune/local-account) PCs pull installers the manifest
references without SMB. Sources: the content-addressed blob store (large
http payloads) first, then an inline DB payload with this hash. The client
re-verifies the sha256, so the hash IS the integrity guarantee. ETag = the
hash (content is immutable).
"""
sha = (sha256 or '').strip().lower()
if len(sha) != 64 or any(c not in '0123456789abcdef' for c in sha):
return error_response(ErrorCodes.VALIDATION_ERROR, 'bad sha256',
http_code=400)
etag = f'"{sha}"'
if request.headers.get('If-None-Match') == etag:
return Response(status=304, headers={'ETag': etag})
blob = db.session.get(ManifestBlob, sha)
if blob and os.path.isfile(service.blob_path(sha)):
response = send_file(
service.blob_path(sha),
mimetype=blob.contenttype or 'application/octet-stream',
as_attachment=True, download_name=blob.filename)
response.headers['ETag'] = etag
return response
inline = ManifestPayload.query.filter_by(payloadsha256=sha).first()
if inline:
return Response(
inline.payloadbytes,
mimetype=inline.contenttype or 'application/octet-stream',
headers={'ETag': etag,
'Content-Disposition': f'attachment; filename="{inline.filename}"'})
return error_response(ErrorCodes.NOT_FOUND, 'No payload for that hash',
http_code=404)
# -- 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)})
@geenforce_bp.route('/applications', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_applications():
"""The core Applications catalog (id + name) for the curated entry->app link
picker in the entry editor."""
apps = Application.query.filter_by(isactive=True).order_by(
Application.appname).all()
return success_response([{'appid': a.appid, 'appname': a.appname}
for a in apps])
# -- scope CRUD (geenforce.manage) --------------------------------------------
def _scope_summary(scope):
current = scope.publishedversions.filter_by(iscurrent=True).first()
return {
'scopeid': scope.scopeid,
'scopename': scope.scopename,
'phase': scope.phase,
'manifestversion': scope.manifestversion,
'description': scope.description,
'computertypeid': scope.computertypeid,
'measuringtooltypeid': scope.measuringtooltypeid,
'iscommon': scope.iscommon,
'entrycount': len(scope.entries),
'publishedversion': current.versionnumber if current else None,
}
def _entry_payload(entry):
"""Manifest-entry dict with entryid + sortorder + the curated app link.
appid/appname are shopdb metadata (not manifest fields), added alongside the
rendered manifest entry for the editor.
"""
data = {'entryid': entry.entryid, 'sortorder': entry.sortorder,
'appid': entry.appid, 'appname': None}
if entry.appid:
app = db.session.get(Application, entry.appid)
data['appname'] = app.appname if app else None
data.update(entry_to_dict(entry))
# Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor.
data['payloadsource'] = entry.payloadsource
data['payloadref'] = entry.payloadref
data['payloadsha256'] = entry.payloadsha256
data['haspayload'] = (entry.payloadsource == 'inline'
and entry.payloadsha256 is not None)
return data
def _apply_app_link(entry, payload):
"""Set the optional curated appid from the payload (shopdb metadata, not a
manifest field, so handled outside populate_entry). Ignores an unknown id."""
if 'appid' not in payload:
return
appid = payload.get('appid')
if appid in (None, '', 0):
entry.appid = None
return
# Ignore a non-numeric id rather than 500 (matches the docstring contract).
try:
appid = int(appid)
except (ValueError, TypeError):
return
if db.session.get(Application, appid):
entry.appid = appid
@geenforce_bp.route('/scopes', methods=['POST'])
@jwt_required()
@require_permission('geenforce.manage')
def create_scope():
payload = request.get_json(silent=True) or {}
scopename = (payload.get('scopename') or '').strip()
phase = (payload.get('phase') or 'runtime').strip()
if not scopename:
return error_response(ErrorCodes.VALIDATION_ERROR,
'scopename is required', http_code=400)
if phase not in PHASES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'phase must be one of {PHASES}', http_code=400)
if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first():
return error_response(ErrorCodes.VALIDATION_ERROR,
'scope already exists', http_code=400)
scope = ManifestScope(
scopename=scopename, phase=phase,
manifestversion=str(payload.get('manifestversion', '1.0')),
description=payload.get('description'),
computertypeid=payload.get('computertypeid'),
measuringtooltypeid=payload.get('measuringtooltypeid'),
iscommon=bool(payload.get('iscommon', scopename == 'common')))
db.session.add(scope)
db.session.commit()
return success_response(_scope_summary(scope), http_code=201)
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_scope(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
data = _scope_summary(scope)
data['entries'] = [_entry_payload(e) for e in scope.entries]
return success_response(data)
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def update_scope(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
payload = request.get_json(silent=True) or {}
for field in ('description', 'computertypeid', 'measuringtooltypeid'):
if field in payload:
setattr(scope, field, payload[field])
if 'manifestversion' in payload:
scope.manifestversion = str(payload['manifestversion'])
if 'iscommon' in payload:
scope.iscommon = bool(payload['iscommon'])
db.session.commit()
return success_response(_scope_summary(scope))
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
@jwt_required()
@require_permission('geenforce.manage')
def delete_scope(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
db.session.delete(scope)
db.session.commit()
return success_response({'deleted': scopeid})
# -- entry CRUD (geenforce.manage) --------------------------------------------
def _validate_entry(payload):
"""Return an error string, or None if the entry payload is valid."""
if not (payload.get('Name') or '').strip():
return 'entry Name is required'
if payload.get('Type') not in ENTRY_TYPES:
return f'entry Type must be one of {ENTRY_TYPES}'
return None
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
@jwt_required()
@require_permission('geenforce.manage')
def create_entry(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
payload = request.get_json(silent=True) or {}
invalid = _validate_entry(payload)
if invalid:
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
entry = build_entry(payload, nextorder)
_apply_app_link(entry, payload)
scope.entries.append(entry)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR,
'an entry with that Name already exists in this scope',
http_code=400)
return success_response(_entry_payload(entry), http_code=201)
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def update_entry(entryid):
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
payload = request.get_json(silent=True) or {}
invalid = _validate_entry(payload)
if invalid:
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
# Free the one-to-one InUseCheck (unique entryid) before populate re-inserts
# it, so the replacement does not collide with the old row mid-flush.
if entry.inusecheck is not None:
db.session.delete(entry.inusecheck)
entry.inusecheck = None
db.session.flush()
populate_entry(entry, payload)
_apply_app_link(entry, payload)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR,
'an entry with that Name already exists in this scope',
http_code=400)
return success_response(_entry_payload(entry))
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
@jwt_required()
@require_permission('geenforce.manage')
def delete_entry(entryid):
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
db.session.delete(entry)
db.session.commit()
return success_response({'deleted': entryid})
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def reorder_entries(scopeid):
"""Set entry order from a list of entryids (the ordering contract)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
order = (request.get_json(silent=True) or {}).get('order') or []
owned = {e.entryid: e for e in scope.entries}
if set(order) != set(owned):
return error_response(ErrorCodes.VALIDATION_ERROR,
'order must list exactly this scope\'s entry ids',
http_code=400)
for position, entryid in enumerate(order):
owned[entryid].sortorder = position
db.session.commit()
return success_response({'order': order})
# -- simulate: "what would this PC get" ---------------------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def simulate_scope(scopeid):
"""Which draft entries apply to a given machine profile, and why the rest
are filtered out (reuses the engine-mirror filters)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
profile = {
'pctype': request.args.get('pctype') or scope.scopename,
'subtype': request.args.get('subtype'),
'hostname': request.args.get('hostname'),
'machinenumber': request.args.get('machinenumber'),
'cmmversion': request.args.get('cmmversion'),
'phase': scope.phase,
}
applied, filtered = [], []
for entry in scope.entries:
entry_dict = entry_to_dict(entry)
if entry_applies(entry_dict, profile):
applied.append(entry.name)
else:
reasons = []
strict_allowed = profile.get('phase') == 'preinstall'
if not matches_pctype(entry_dict, profile['pctype'],
profile['subtype'], strict_allowed):
reasons.append('PCTypes')
if not matches_hostname(entry_dict, profile['hostname']):
reasons.append('TargetHostnames')
if not matches_machinenumber(entry_dict, profile['machinenumber']):
reasons.append('TargetMachineNumbers')
if not matches_cmmversion(entry_dict, profile['cmmversion']):
reasons.append('_CmmVersion')
filtered.append({'name': entry.name, 'filteredby': reasons})
return success_response({'profile': profile, 'applied': applied,
'filtered': filtered})
# -- compliance: "how much of the fleet has this app" -------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def scope_compliance(scopeid):
"""Fleet-install coverage per app-linked entry (from collected PC data).
One row per entry that carries a curated appid; installed/version-match
counts come from the computers plugin's ComputerInstalledApp. Degrades to
null counts (computersplugin: false) when that plugin is absent.
"""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
http_code=404)
return success_response(service.compliance_for_scope(scope))
# -- inline payload upload / fetch (small scripts + configs) ------------------
PAYLOAD_MAX_BYTES = 1024 * 1024
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def upload_entry_payload(entryid):
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
uploaded = request.files.get('file')
if uploaded is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'a file is required', http_code=400)
rawbytes = uploaded.read()
if not rawbytes:
return error_response(ErrorCodes.VALIDATION_ERROR,
'payload is empty', http_code=400)
if len(rawbytes) > PAYLOAD_MAX_BYTES:
return error_response(ErrorCodes.VALIDATION_ERROR,
'payload exceeds 1 MB limit', http_code=400)
service.store_inline_payload(entry, uploaded.filename,
uploaded.mimetype, rawbytes)
db.session.commit()
return success_response(_entry_payload(entry), http_code=201)
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def download_entry_payload(entryid):
"""Return the stored inline payload bytes for an entry (404 if none)."""
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
payload = ManifestPayload.query.filter_by(entryid=entryid).first()
if not payload:
return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload',
http_code=404)
return Response(
payload.payloadbytes,
mimetype=payload.contenttype or 'application/octet-stream',
headers={'Content-Disposition':
f'attachment; filename="{payload.filename}"'})
# -- publish lifecycle (geenforce.publish) ------------------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def publish_scope_route(scopeid):
scope = db.session.get(ManifestScope, 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')
try:
publishedby = int(get_jwt_identity())
except (TypeError, ValueError):
publishedby = None
version = service.publish_scope(scope.scopename, scope.phase,
notes=notes, publishedby=publishedby)
db.session.commit()
return success_response({'versionnumber': version}, http_code=201)
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_versions(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
versions = ManifestPublishedVersion.query.filter_by(
scopeid=scopeid).order_by(
ManifestPublishedVersion.versionnumber.desc()).all()
return success_response([{
'versionnumber': v.versionnumber,
'iscurrent': v.iscurrent,
'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None,
'publishedby': v.publishedby,
'notes': v.notes,
} for v in versions])
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_version(scopeid, versionnumber):
version = ManifestPublishedVersion.query.filter_by(
scopeid=scopeid, versionnumber=versionnumber).first()
if not version:
return error_response(ErrorCodes.NOT_FOUND, 'No such version',
http_code=404)
import json as _json
return success_response({'versionnumber': version.versionnumber,
'manifest': _json.loads(version.manifestjson)})
@geenforce_bp.route('/config', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_config():
"""Plugin config: the on-share export root (for export-to-share)."""
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
return success_response({'shareroot': setting.value if setting else ''})
@geenforce_bp.route('/config', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.publish')
def put_config():
payload = request.get_json(silent=True) or {}
Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(),
valuetype='string', category='geenforce',
description='On-share export root for GE-Enforce manifests')
db.session.commit()
return success_response({'shareroot': (payload.get('shareroot') or '').strip()})
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def export_share_route(scopeid):
"""Write the scope's current published JSON to the configured share root
(backing up the old file to _meta/history first)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
shareroot = setting.value if setting else ''
if not shareroot:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Configure the share root first', http_code=400)
try:
path = service.export_scope_to_share(scope.scopename, scope.phase,
shareroot)
except (ValueError, OSError) as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
return success_response({'path': path})
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def rollback_scope_route(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
versionnumber = (request.get_json(silent=True) or {}).get('versionnumber')
if versionnumber is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'versionnumber is required', http_code=400)
try:
service.rollback_scope(scope.scopename, scope.phase, int(versionnumber))
db.session.commit()
except ValueError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
return success_response({'versionnumber': int(versionnumber)})
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],
})