Build GE-Enforce manifest-store plugin (P0/P1): model, importer, parity gate
First execution phases of docs/proposals/ge-enforce-plugin.md. The GE-Enforce manifest becomes shopdb data. P0 scaffold: new geenforce plugin (api_prefix /api/geenforce, default_enabled false, core_version >=0.7.0). Registered in PLUGIN_TABLE_OWNERS (ADR-008); its 0001 baseline really creates the tables. P1a model: one wide manifestentries table + entrytype discriminator (not STI, not JSON blob), manifestscopes (UNIQUE scopename+phase), the three multi-value filter child tables, inusechecks + processes, immutable manifestpublishedversions (frozen rendered JSON), manifestpayloads (inline, capped), pctypealiases (mirror of the engine lib's alias graph). regvalue stored as its raw JSON literal so DWord typing survives. P1c importer + exporter: parse common + gea-shopfloor-* + preinstall.json into draft rows and rebuild the JSON verbatim from rows in sortorder. P1d parity harness (GATE A): filters.py mirrors the engine's four filter functions + alias graph; parity.py proves import+export is behaviorally lossless (field-identical + same-entries-fire across 18 machine-profile fixtures) WITHOUT byte-diffing. Verified PASS against all 11 real reference manifests (64 entries) and a synthetic site-neutral fixture covering every type/filter (the CI gate). First slice (gea-shopfloor-cmm shape): service layer (import/publish/rollback/ export-to-share), CLI (parity, import-share, publish, export-share), and the client endpoint GET /api/geenforce/manifest serving the current published snapshot (never the draft) with ETag/304. Split permissions geenforce.manage/publish/fetch. Tests prove import->publish->serve, draft edits never change served bytes, publish+rollback, and auth (401 unauth/wrong-scope). Contract 0.11.0: added service_token_authorized(scope) to shopdb.api so plugin service endpoints authorize a scoped managed token without importing core token internals. Documented in PLUGIN-HOOKS.md. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
5
plugins/geenforce/api/__init__.py
Normal file
5
plugins/geenforce/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""GE-Enforce plugin API blueprint."""
|
||||
|
||||
from .routes import geenforce_bp
|
||||
|
||||
__all__ = ['geenforce_bp']
|
||||
117
plugins/geenforce/api/routes.py
Normal file
117
plugins/geenforce/api/routes.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""GE-Enforce plugin API.
|
||||
|
||||
Two audiences:
|
||||
- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest.
|
||||
Full CRUD + publish lands in P2; this is the P1/first-slice read surface.
|
||||
- Client (service token, geenforce.fetch scope): GET /manifest serves the
|
||||
CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the
|
||||
collector's managed-token pattern (X-API-Key or Bearer PAT), plus an optional
|
||||
GEENFORCE_API_KEY env bootstrap.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, current_app, Response
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
service_token_authorized,
|
||||
)
|
||||
|
||||
from ..models import ManifestScope, ManifestPublishedVersion
|
||||
from ..serializer import scope_to_manifest
|
||||
|
||||
geenforce_bp = Blueprint('geenforce', __name__)
|
||||
|
||||
FETCH_SCOPE = 'geenforce.fetch'
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
# -- 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)})
|
||||
|
||||
|
||||
# -- 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)})
|
||||
Reference in New Issue
Block a user