From the full multi-agent review (0 high, 7 medium, 17 low findings). Applies the mechanical, low-risk items; design/policy findings left for a decision. Docs accuracy: CLAUDE.md contract 0.10.0 -> 0.11.0 and both stale Alembic head citations -> 7d24_customfield_searchable / 31 migrations; Dockerfile bundled- plugin comment fixed (drop nonexistent "equipment", add machines + measuringtools, count eleven). Style/naming (LOCKED rules): remove a CSS-escaped pushpin emoji before location search results (no-emoji policy); rename ManifestEditor shareRoot -> shareroot (variable mirrors the API field verbatim). Dead code: remove confirmed-unused imports across ~20 modules (require_role/ require_permission scaffold residue, stray db/Vendor/Model/current_user/Optional/ error_response); drop unused build_scope import + a stale GEENFORCE_API_KEY docstring clause in geenforce. Migration files left untouched. Correctness: geenforce ingest robustness - record_enforcement_report now 400s on a non-dict counts / non-list results instead of 500; _apply_app_link ignores a non-numeric appid per its docstring instead of 500. Regression tests added. Backend query.get sweep finished: auth.py refresh -> db.session.get (last one). 910 backend tests pass; pyflakes clean; naming green; frontend build green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
713 lines
28 KiB
Python
713 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).
|
|
"""
|
|
|
|
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
|
|
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],
|
|
})
|