Files
shopdb-flask/plugins/geenforce/plugin.py
cproudlock 9e34fafce5 geenforce: refuse to publish a manifest the fleet's lib cannot read
The engine treats a minor-newer manifest as backward compatible and carries
on. That holds for additions which WIDEN behaviour - an old lib skips a Type
it does not know - and inverts for one that NARROWS it. _CmmVersion arrived
in lib 2.6 as a minor bump, so a PC on 2.5 does not recognise the field,
reads every gated entry as unfiltered, and installs every PC-DMIS version it
cannot detect, on every CMM, within one cycle.

The share runbook already says push the lib first. A runbook is not a
control, and the failure is silent, fleet-wide and about five minutes fast.

ShopDB already had the evidence and was not using it: every enforcement
report carries the enforcer version, and publish_scope had no gate at all.
It now compares the scope's manifest version against the versions PCs
actually report for that scope and refuses when any is behind, naming the
hosts. force=True for someone who knows why. A report with no or an
unreadable version counts as behind - that field arrived with the
summary-emitting engine, so its absence IS an old lib, and treating unknown
as safe is precisely how this fails open.

A scope nobody has reported for still publishes, or a fresh site could
never publish anything. Versions compare numerically, since as text '2.10'
sorts below '2.9'.

Also exposed as a preflight endpoint so the UI can warn before someone
clicks publish, and as a 409 with the offending hosts rather than a 500.
2026-08-12 15:23:57 -04:00

273 lines
11 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, ManifestBlob,
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, ManifestBlob,
ManifestEnforcementReport, ManifestEnforcementResult, PcTypeAlias,
]
def get_dashboard_widgets(self) -> List[Dict]:
"""Dashboard cards this plugin contributes.
DATA AND SHAPE, not a component name. The older widget contract named a
Vue component per widget, which cannot survive a lean build - a
plugin's component may never be staged into the frontend bundle - and
is why five plugins declared widgets pointing at components nobody ever
wrote. Core owns a small set of generic renderers; a plugin says what
to show and how to link it. Same lesson ADR-010 already applied to
asset panels.
`empty: hide` matters as much as the data. A card that reports "nothing
wrong" every day teaches people to stop reading the page, which is
exactly how a fleet log reached 3,234 lines with 17 that mattered.
"""
return [
{
'id': 'geenforce-failures',
'viewall': '/geenforce',
'title': 'Enforcement failures',
'endpoint': '/api/geenforce/dashboard/failures',
'render': 'exceptions',
'severity': 'critical',
'permission': 'geenforce.manage',
'empty': 'hide',
'position': 10,
'map': {
'title': 'hostname',
'detail': 'entryname',
'meta': [{'key': 'message'}, {'key': 'exitcode',
'label': 'exit'}],
'link': '/pcs/{computerid}',
'timestamp': 'receivedat',
},
},
]
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_navigation_items(self) -> List[Dict]:
"""Top-level sidebar section (GE-Enforce is a large operational surface,
not a mere setting). The tabbed shell hosts Manifests + Reports."""
return [
{
'name': 'GE-Enforce',
'icon': 'shield',
'route': '/geenforce/manifests',
'position': 46,
},
]
def get_settings_cards(self) -> List[Dict]:
"""Admin config for GE-Enforce lives in the Settings rail."""
return [
{
'group': 'GE-Enforce',
'to': '/settings/geenforce',
'icon': 'shield',
'title': 'GE-Enforce Settings',
'description': 'Client IP allowlist for token-less fleet access',
'position': 46,
},
]
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
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)
@click.option('--force', is_flag=True,
help='Publish even when reporting PCs run an older '
'enforcer lib than the manifest requires.')
def publish_cmd(scopename, phase, notes, force):
"""Freeze the current draft of a scope into a published snapshot."""
from flask import current_app
from .service import publish_scope, LibVersionTooOldError
with current_app.app_context():
try:
version = publish_scope(scopename, phase, notes=notes,
force=force)
except LibVersionTooOldError as error:
click.echo(click.style(f'REFUSED: {error}', fg='red'))
raise SystemExit(1)
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}.")
@geenforce_cli.command('add-payload')
@click.argument('filepath')
@click.option('--contenttype', default=None)
def add_payload_cmd(filepath, contenttype):
"""Store a file in the content-addressed payload store for HTTPS
delivery, and print its sha256. Set an entry's PayloadSource=http +
PayloadSha256 to serve it from GET /api/geenforce/payload/<sha>."""
import os as _os
from flask import current_app
from .service import store_blob
with current_app.app_context():
with open(filepath, 'rb') as handle:
raw = handle.read()
sha = store_blob(raw, _os.path.basename(filepath), contenttype)
db.session.commit()
click.echo(f"stored {len(raw)} bytes")
click.echo(f"PayloadSha256: {sha}")
click.echo(f"URL: /api/geenforce/payload/{sha}")
return [geenforce_cli]