Files
shopdb-flask/plugins/geenforce/plugin.py
cproudlock 7eb6cebeb5
All checks were successful
CI / backend (push) Successful in 1m41s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
Add GE-Enforce P3 manifest editor UI (Settings > Imaging PC Types)
The imaging-PC-type manifest editor, contributed as an ADR-010 settings card
(Integrations group) and an ADR-009 plugin-gated route
(/settings/imagingpctypes, hidden when geenforce is disabled).

- Scope list: every imaging PC type with phase, entry count, and published
  version (or "unpublished"). New PC Type button.
- Scope detail: ComputerType/MeasuringToolType mapping + description; Publish,
  Versions (with per-version Roll Back), Preview (draft JSON), Delete.
- Entry table: ordered with Move Up/Down (the ordering contract, not drag),
  Name/Type/Detection/Filters, Edit/Delete. Add Entry opens a typed modal whose
  fields switch on entry Type (MSI/EXE/... vs PS1 vs File vs Registry), with a
  detection block, comma-separated targeting filters, CMM version gate, payload
  source, and an Advanced disclosure for the inert ApplyMode/UpdateWindow and
  InUseCheck. RegValue is typed by RegType (DWord/QWord -> number).
- Simulator: "what would a PC get" - enter a machine profile, see which entries
  apply and which filter excluded the rest. Verified live: CMM version 2019 ->
  applies 2019 + untagged, filters 2016/2026 by _CmmVersion.

Uses the P2 admin API; JWT+admin gated. Frontend build + naming green; full
backend suite 876 green.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 17:22:49 -04:00

198 lines
8.0 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, 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 get_settings_cards(self) -> List[Dict]:
"""ADR-010: the imaging-PC-type manifest editor settings card."""
return [
{
'group': 'Integrations',
'to': '/settings/imagingpctypes',
'icon': 'settings',
'title': 'Imaging PC Types',
'description': 'Edit imaging PC types and their GE-Enforce install '
'manifests (apps, scripts, files, registry, gates); '
'publish, roll back, and simulate.',
'position': 30,
},
]
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]