Wave one of the dashboard proposal, built as a vertical slice so the contract is proven by something real before the other five cards follow. GET /api/geenforce/dashboard/failures lists entries that FAILED on their PC's most recent enforcement cycle. Per ENTRY, not per report: "three PCs failed" is a number, while "Install OpenText failed with exit 1603 on WJSF1234" is something a person can act on. Only current reports count, so a failure that has since been fixed clears itself instead of needing dismissing. Hostnames resolve to computerids in one query so each row links to the PC, and a PC shopdb does not know still appears - the failure is real even when the inventory is behind, and that is the bay most likely to be misconfigured. The data has been there all along. The only way to see any of it was to open one PC's report modal, one PC at a time. The widget declaration is the contract change. The old shape named a Vue component per widget, which cannot survive a lean build where a plugin's component may never be staged into the bundle - which is exactly why five plugins declare widgets pointing at components nobody ever wrote. This declares data, a generic renderer, a permission and a link template, the way ADR-010 already does for asset panels. A test asserts no 'component' key, so the old shape cannot creep back. empty: hide is part of the contract, not decoration. A card reporting "nothing wrong" daily teaches people to stop reading the page, which is how a fleet log reached 3,234 lines with 17 that mattered. Frontend rendering comes next; the endpoint and declaration stand alone and change nothing that exists.
264 lines
11 KiB
Python
264 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',
|
|
'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)
|
|
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}.")
|
|
|
|
@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]
|