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:
178
plugins/geenforce/plugin.py
Normal file
178
plugins/geenforce/plugin.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""GE-Enforce manifest-store plugin.
|
||||
|
||||
Owns the imaging-PC-type scopes and their install manifests as shopdb data
|
||||
(see docs/proposals/ge-enforce-plugin.md). This is the P0/P1 foundation: models,
|
||||
the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and
|
||||
client cutover come in later phases.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
import click
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db
|
||||
|
||||
from .api import geenforce_bp
|
||||
from .models import (
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
||||
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion, ManifestPayload, PcTypeAlias,
|
||||
)
|
||||
from .filters import ALIAS_GROUPS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GeEnforcePlugin(BasePlugin):
|
||||
"""Desired-state manifest store for the GE-Enforce client."""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as handle:
|
||||
return json.load(handle)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'geenforce'),
|
||||
version=self._manifest.get('version', '0.1.0'),
|
||||
description=self._manifest.get('description', 'GE-Enforce manifest store'),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.7.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/geenforce'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return geenforce_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType,
|
||||
ManifestEntryHostname, ManifestEntryMachineNumber,
|
||||
ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion, ManifestPayload, PcTypeAlias,
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""RBAC permissions this plugin owns (edit vs ship are split)."""
|
||||
return [
|
||||
('geenforce.manage', 'Edit imaging PC types and manifest drafts',
|
||||
'geenforce'),
|
||||
('geenforce.publish', 'Publish, roll back, and export manifests',
|
||||
'geenforce'),
|
||||
('geenforce.fetch', 'Fetch published manifests (client service token)',
|
||||
'geenforce'),
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
with app.app_context():
|
||||
self._seed_aliases()
|
||||
db.session.commit()
|
||||
logger.info("GE-Enforce plugin installed")
|
||||
|
||||
def _seed_aliases(self) -> None:
|
||||
"""Seed pctypealiases from the engine lib's alias graph (idempotent)."""
|
||||
for group_index, group in enumerate(ALIAS_GROUPS):
|
||||
for aliasname in group:
|
||||
exists = PcTypeAlias.query.filter_by(
|
||||
aliasgroup=group_index, aliasname=aliasname).first()
|
||||
if not exists:
|
||||
db.session.add(PcTypeAlias(
|
||||
aliasgroup=group_index, aliasname=aliasname))
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""CLI: parity (Gate A), import-share, export-share, publish."""
|
||||
|
||||
@click.group('geenforce')
|
||||
def geenforce_cli():
|
||||
"""GE-Enforce manifest-store commands."""
|
||||
|
||||
@geenforce_cli.command('parity')
|
||||
@click.option('--shareroot', required=True,
|
||||
help='GE-Enforce share root (contains common/, '
|
||||
'gea-shopfloor-*/).')
|
||||
@click.option('--preinstall', default=None,
|
||||
help='Optional path to a preinstall.json to include.')
|
||||
def parity_cmd(shareroot, preinstall):
|
||||
"""Prove import+export is behaviorally lossless (Gate A). DB-free."""
|
||||
from .importer import discover_share, load_manifest_file
|
||||
from .parity import run_parity, format_result
|
||||
|
||||
manifests = list(discover_share(shareroot))
|
||||
if preinstall:
|
||||
manifests.append(
|
||||
('preinstall', 'preinstall', load_manifest_file(preinstall)))
|
||||
results, ok = run_parity(manifests)
|
||||
for result in results:
|
||||
click.echo(format_result(result))
|
||||
click.echo(f"RESULT: {'PASS' if ok else 'FAIL'} "
|
||||
f"({len(results)} scopes)")
|
||||
raise SystemExit(0 if ok else 1)
|
||||
|
||||
@geenforce_cli.command('import-share')
|
||||
@click.option('--shareroot', required=True)
|
||||
@click.option('--preinstall', default=None)
|
||||
@click.option('--scope', default=None,
|
||||
help='Import only this scope name (else all).')
|
||||
def import_share_cmd(shareroot, preinstall, scope):
|
||||
"""Import on-share manifests into draft rows (idempotent rebuild)."""
|
||||
from flask import current_app
|
||||
from .importer import discover_share, load_manifest_file, build_scope
|
||||
from .service import replace_scope_draft
|
||||
|
||||
with current_app.app_context():
|
||||
sources = list(discover_share(shareroot))
|
||||
if preinstall:
|
||||
sources.append(('preinstall', 'preinstall',
|
||||
load_manifest_file(preinstall)))
|
||||
count = 0
|
||||
for name, phase, manifest in sources:
|
||||
if scope and name != scope:
|
||||
continue
|
||||
replace_scope_draft(name, phase, manifest)
|
||||
count += 1
|
||||
db.session.commit()
|
||||
click.echo(f"Imported {count} scope(s).")
|
||||
|
||||
@geenforce_cli.command('publish')
|
||||
@click.argument('scopename')
|
||||
@click.option('--phase', default='runtime')
|
||||
@click.option('--notes', default=None)
|
||||
def publish_cmd(scopename, phase, notes):
|
||||
"""Freeze the current draft of a scope into a published snapshot."""
|
||||
from flask import current_app
|
||||
from .service import publish_scope
|
||||
|
||||
with current_app.app_context():
|
||||
version = publish_scope(scopename, phase, notes=notes)
|
||||
db.session.commit()
|
||||
click.echo(f"Published {scopename}/{phase} as v{version}.")
|
||||
|
||||
@geenforce_cli.command('export-share')
|
||||
@click.argument('scopename')
|
||||
@click.option('--shareroot', required=True)
|
||||
@click.option('--phase', default='runtime')
|
||||
def export_share_cmd(scopename, shareroot, phase):
|
||||
"""Write a scope's published JSON to the share (with history backup)."""
|
||||
from flask import current_app
|
||||
from .service import export_scope_to_share
|
||||
|
||||
with current_app.app_context():
|
||||
path = export_scope_to_share(scopename, phase, shareroot)
|
||||
click.echo(f"Exported to {path}.")
|
||||
|
||||
return [geenforce_cli]
|
||||
Reference in New Issue
Block a user