Fleet-install compliance for app-linked manifest entries: new service compliance_for_scope + GET /geenforce/scopes/<id>/compliance count active ComputerInstalledApp rows by curated appid (null-safe when computers plugin absent). ManifestEditor gains a compliance panel. Curated appid stays shopdb metadata and never enters manifest JSON, so behavioral parity is unaffected. Inline manifest payloads: store_inline_payload (sha256, 1MB cap, payloadsource='inline') + POST/GET /geenforce/entries/<id>/payload; editor gains an upload control. Entry payload metadata surfaced in _entry_payload. Frontend test harness: extract the editor's entry-form logic into pure entryForm.js (buildEntryPayload, describeEntry, availableEntryTypes, scope gates, ...) and cover it with 45 vitest tests. ManifestEditor now imports those helpers, so the tests exercise the shipped code path (no duplication). 908 backend tests pass; vitest 45 pass; frontend build green; naming green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
708 lines
28 KiB
Python
708 lines
28 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, Response
|
|
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, 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 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
|
|
elif db.session.get(Application, int(appid)):
|
|
entry.appid = int(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],
|
|
})
|