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>
179 lines
7.2 KiB
Python
179 lines
7.2 KiB
Python
"""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]
|