"""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, ManifestEnforcementReport, ManifestEnforcementResult, 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, ManifestEnforcementReport, ManifestEnforcementResult, 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'), ('geenforce.report', 'Report enforcement results (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]