Initial import: shopdb-flask multi-site asset platform

Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin
framework so sister GE Aerospace sites can adopt it. Product version
0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests.

Key entry points:
- docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook
  (own-site and subpath deployment methods)
- docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify
- docs/IMPORT-API.md - schema-agnostic legacy data import contract
- scripts/site_imports/wjf/ - West Jefferson reference import loader
- docs/adr/ - twelve accepted architecture decision records
This commit is contained in:
2026-07-14 07:58:51 -04:00
commit a9d0dfff4a
599 changed files with 88557 additions and 0 deletions

View File

@@ -0,0 +1,5 @@
"""GE-Enforce manifest-store plugin package."""
from .plugin import GeEnforcePlugin
__all__ = ['GeEnforcePlugin']

View File

@@ -0,0 +1,5 @@
"""GE-Enforce plugin API blueprint."""
from .routes import geenforce_bp
__all__ = ['geenforce_bp']

View File

@@ -0,0 +1,712 @@
"""GE-Enforce plugin API.
Two audiences:
- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest.
Full CRUD + publish lands in P2; this is the P1/first-slice read surface.
- Client (service token, geenforce.fetch scope): GET /manifest serves the
CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the
collector's managed-token pattern (X-API-Key or Bearer PAT).
"""
from functools import wraps
from flask import Blueprint, request, Response
from flask_jwt_extended import jwt_required
from sqlalchemy.exc import IntegrityError
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
service_token_authorized, Setting, Application,
)
SHAREROOT_SETTING = 'geenforce_share_root'
from ..models import (
ManifestScope, ManifestEntry, ManifestPublishedVersion,
ManifestEnforcementReport, ManifestPayload, ENTRY_TYPES, PHASES,
)
from ..serializer import scope_to_manifest, entry_to_dict
from ..importer import build_entry, populate_entry
from ..filters import (
entry_applies, matches_pctype, matches_hostname, matches_machinenumber,
matches_cmmversion,
)
from .. import service
geenforce_bp = Blueprint('geenforce', __name__)
FETCH_SCOPE = 'geenforce.fetch'
REPORT_SCOPE = 'geenforce.report'
def _require_service_token(scope):
"""Decorator factory: require a managed service token scoped for `scope`.
Tokens are the only client auth path (the client kit provisions a
geenforce.fetch/report token). No env-key fallback: it was never wired into
config and an unscoped shared key is a needless backdoor.
"""
def wrapper(f):
@wraps(f)
def decorated(*args, **kwargs):
if service_token_authorized(scope):
return f(*args, **kwargs)
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
return decorated
return wrapper
require_fetch_token = _require_service_token(FETCH_SCOPE)
require_report_token = _require_service_token(REPORT_SCOPE)
# -- client-facing endpoint ---------------------------------------------------
@geenforce_bp.route('/manifest', methods=['GET'])
@require_fetch_token
def get_manifest():
"""Serve the current published manifest for a scope (fat-client: full scope).
Query: pctype (=scopename, required), phase (default runtime). The engine
filters client-side, matching today, so subtype/hostname/machinenumber/
cmmversion are accepted but not applied here.
"""
scopename = (request.args.get('pctype') or '').strip()
phase = (request.args.get('phase') or 'runtime').strip()
if not scopename:
return error_response(ErrorCodes.VALIDATION_ERROR,
'pctype is required', http_code=400)
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
return error_response(ErrorCodes.NOT_FOUND,
f'No scope: {scopename}', http_code=404)
published = scope.publishedversions.filter_by(iscurrent=True).first()
if not published:
return error_response(ErrorCodes.NOT_FOUND,
f'{scopename} has no published version',
http_code=404)
etag = f'"{scope.scopeid}-v{published.versionnumber}"'
if request.headers.get('If-None-Match') == etag:
return Response(status=304, headers={'ETag': etag})
return Response(published.manifestjson, mimetype='application/json',
headers={'ETag': etag,
'X-Manifest-Version': str(published.versionnumber)})
# -- client reporting (observed state) ----------------------------------------
@geenforce_bp.route('/report', methods=['POST'])
@require_report_token
def post_report():
"""Record one PC's enforcement cycle: applied version (did it receive the
update?) + per-entry self-heal outcomes (installed/skipped/failed)."""
payload = request.get_json(silent=True) or {}
if not (payload.get('hostname') or '').strip():
return error_response(ErrorCodes.VALIDATION_ERROR,
'hostname is required', http_code=400)
try:
report = service.record_enforcement_report(payload)
db.session.commit()
except ValueError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc),
http_code=400)
return success_response({'reportid': report.reportid,
'status': report.status}, message='recorded')
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
@geenforce_bp.route('/scopes', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_scopes():
"""List imaging PC-type scopes with entry + published-version counts."""
scopes = ManifestScope.query.order_by(
ManifestScope.phase, ManifestScope.scopename).all()
data = []
for scope in scopes:
current = scope.publishedversions.filter_by(iscurrent=True).first()
data.append({
'scopeid': scope.scopeid,
'scopename': scope.scopename,
'phase': scope.phase,
'manifestversion': scope.manifestversion,
'computertypeid': scope.computertypeid,
'measuringtooltypeid': scope.measuringtooltypeid,
'iscommon': scope.iscommon,
'entrycount': len(scope.entries),
'publishedversion': current.versionnumber if current else None,
})
return success_response(data)
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def preview_scope(scopeid):
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
http_code=404)
return success_response({'scopename': scope.scopename,
'manifest': scope_to_manifest(scope)})
@geenforce_bp.route('/applications', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_applications():
"""The core Applications catalog (id + name) for the curated entry->app link
picker in the entry editor."""
apps = Application.query.filter_by(isactive=True).order_by(
Application.appname).all()
return success_response([{'appid': a.appid, 'appname': a.appname}
for a in apps])
# -- scope CRUD (geenforce.manage) --------------------------------------------
def _scope_summary(scope):
current = scope.publishedversions.filter_by(iscurrent=True).first()
return {
'scopeid': scope.scopeid,
'scopename': scope.scopename,
'phase': scope.phase,
'manifestversion': scope.manifestversion,
'description': scope.description,
'computertypeid': scope.computertypeid,
'measuringtooltypeid': scope.measuringtooltypeid,
'iscommon': scope.iscommon,
'entrycount': len(scope.entries),
'publishedversion': current.versionnumber if current else None,
}
def _entry_payload(entry):
"""Manifest-entry dict with entryid + sortorder + the curated app link.
appid/appname are shopdb metadata (not manifest fields), added alongside the
rendered manifest entry for the editor.
"""
data = {'entryid': entry.entryid, 'sortorder': entry.sortorder,
'appid': entry.appid, 'appname': None}
if entry.appid:
app = db.session.get(Application, entry.appid)
data['appname'] = app.appname if app else None
data.update(entry_to_dict(entry))
# Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor.
data['payloadsource'] = entry.payloadsource
data['payloadref'] = entry.payloadref
data['payloadsha256'] = entry.payloadsha256
data['haspayload'] = (entry.payloadsource == 'inline'
and entry.payloadsha256 is not None)
return data
def _apply_app_link(entry, payload):
"""Set the optional curated appid from the payload (shopdb metadata, not a
manifest field, so handled outside populate_entry). Ignores an unknown id."""
if 'appid' not in payload:
return
appid = payload.get('appid')
if appid in (None, '', 0):
entry.appid = None
return
# Ignore a non-numeric id rather than 500 (matches the docstring contract).
try:
appid = int(appid)
except (ValueError, TypeError):
return
if db.session.get(Application, appid):
entry.appid = appid
@geenforce_bp.route('/scopes', methods=['POST'])
@jwt_required()
@require_permission('geenforce.manage')
def create_scope():
payload = request.get_json(silent=True) or {}
scopename = (payload.get('scopename') or '').strip()
phase = (payload.get('phase') or 'runtime').strip()
if not scopename:
return error_response(ErrorCodes.VALIDATION_ERROR,
'scopename is required', http_code=400)
if phase not in PHASES:
return error_response(ErrorCodes.VALIDATION_ERROR,
f'phase must be one of {PHASES}', http_code=400)
if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first():
return error_response(ErrorCodes.VALIDATION_ERROR,
'scope already exists', http_code=400)
scope = ManifestScope(
scopename=scopename, phase=phase,
manifestversion=str(payload.get('manifestversion', '1.0')),
description=payload.get('description'),
computertypeid=payload.get('computertypeid'),
measuringtooltypeid=payload.get('measuringtooltypeid'),
iscommon=bool(payload.get('iscommon', scopename == 'common')))
db.session.add(scope)
db.session.commit()
return success_response(_scope_summary(scope), http_code=201)
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_scope(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
data = _scope_summary(scope)
data['entries'] = [_entry_payload(e) for e in scope.entries]
return success_response(data)
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def update_scope(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
payload = request.get_json(silent=True) or {}
for field in ('description', 'computertypeid', 'measuringtooltypeid'):
if field in payload:
setattr(scope, field, payload[field])
if 'manifestversion' in payload:
scope.manifestversion = str(payload['manifestversion'])
if 'iscommon' in payload:
scope.iscommon = bool(payload['iscommon'])
db.session.commit()
return success_response(_scope_summary(scope))
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
@jwt_required()
@require_permission('geenforce.manage')
def delete_scope(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
db.session.delete(scope)
db.session.commit()
return success_response({'deleted': scopeid})
# -- entry CRUD (geenforce.manage) --------------------------------------------
def _validate_entry(payload):
"""Return an error string, or None if the entry payload is valid."""
if not (payload.get('Name') or '').strip():
return 'entry Name is required'
if payload.get('Type') not in ENTRY_TYPES:
return f'entry Type must be one of {ENTRY_TYPES}'
return None
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
@jwt_required()
@require_permission('geenforce.manage')
def create_entry(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
payload = request.get_json(silent=True) or {}
invalid = _validate_entry(payload)
if invalid:
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
entry = build_entry(payload, nextorder)
_apply_app_link(entry, payload)
scope.entries.append(entry)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR,
'an entry with that Name already exists in this scope',
http_code=400)
return success_response(_entry_payload(entry), http_code=201)
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def update_entry(entryid):
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
payload = request.get_json(silent=True) or {}
invalid = _validate_entry(payload)
if invalid:
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
# Free the one-to-one InUseCheck (unique entryid) before populate re-inserts
# it, so the replacement does not collide with the old row mid-flush.
if entry.inusecheck is not None:
db.session.delete(entry.inusecheck)
entry.inusecheck = None
db.session.flush()
populate_entry(entry, payload)
_apply_app_link(entry, payload)
try:
db.session.commit()
except IntegrityError:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR,
'an entry with that Name already exists in this scope',
http_code=400)
return success_response(_entry_payload(entry))
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
@jwt_required()
@require_permission('geenforce.manage')
def delete_entry(entryid):
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
db.session.delete(entry)
db.session.commit()
return success_response({'deleted': entryid})
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.manage')
def reorder_entries(scopeid):
"""Set entry order from a list of entryids (the ordering contract)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
order = (request.get_json(silent=True) or {}).get('order') or []
owned = {e.entryid: e for e in scope.entries}
if set(order) != set(owned):
return error_response(ErrorCodes.VALIDATION_ERROR,
'order must list exactly this scope\'s entry ids',
http_code=400)
for position, entryid in enumerate(order):
owned[entryid].sortorder = position
db.session.commit()
return success_response({'order': order})
# -- simulate: "what would this PC get" ---------------------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def simulate_scope(scopeid):
"""Which draft entries apply to a given machine profile, and why the rest
are filtered out (reuses the engine-mirror filters)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
profile = {
'pctype': request.args.get('pctype') or scope.scopename,
'subtype': request.args.get('subtype'),
'hostname': request.args.get('hostname'),
'machinenumber': request.args.get('machinenumber'),
'cmmversion': request.args.get('cmmversion'),
'phase': scope.phase,
}
applied, filtered = [], []
for entry in scope.entries:
entry_dict = entry_to_dict(entry)
if entry_applies(entry_dict, profile):
applied.append(entry.name)
else:
reasons = []
strict_allowed = profile.get('phase') == 'preinstall'
if not matches_pctype(entry_dict, profile['pctype'],
profile['subtype'], strict_allowed):
reasons.append('PCTypes')
if not matches_hostname(entry_dict, profile['hostname']):
reasons.append('TargetHostnames')
if not matches_machinenumber(entry_dict, profile['machinenumber']):
reasons.append('TargetMachineNumbers')
if not matches_cmmversion(entry_dict, profile['cmmversion']):
reasons.append('_CmmVersion')
filtered.append({'name': entry.name, 'filteredby': reasons})
return success_response({'profile': profile, 'applied': applied,
'filtered': filtered})
# -- compliance: "how much of the fleet has this app" -------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def scope_compliance(scopeid):
"""Fleet-install coverage per app-linked entry (from collected PC data).
One row per entry that carries a curated appid; installed/version-match
counts come from the computers plugin's ComputerInstalledApp. Degrades to
null counts (computersplugin: false) when that plugin is absent.
"""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
http_code=404)
return success_response(service.compliance_for_scope(scope))
# -- inline payload upload / fetch (small scripts + configs) ------------------
PAYLOAD_MAX_BYTES = 1024 * 1024
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def upload_entry_payload(entryid):
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
uploaded = request.files.get('file')
if uploaded is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'a file is required', http_code=400)
rawbytes = uploaded.read()
if not rawbytes:
return error_response(ErrorCodes.VALIDATION_ERROR,
'payload is empty', http_code=400)
if len(rawbytes) > PAYLOAD_MAX_BYTES:
return error_response(ErrorCodes.VALIDATION_ERROR,
'payload exceeds 1 MB limit', http_code=400)
service.store_inline_payload(entry, uploaded.filename,
uploaded.mimetype, rawbytes)
db.session.commit()
return success_response(_entry_payload(entry), http_code=201)
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def download_entry_payload(entryid):
"""Return the stored inline payload bytes for an entry (404 if none)."""
entry = db.session.get(ManifestEntry, entryid)
if not entry:
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
payload = ManifestPayload.query.filter_by(entryid=entryid).first()
if not payload:
return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload',
http_code=404)
return Response(
payload.payloadbytes,
mimetype=payload.contenttype or 'application/octet-stream',
headers={'Content-Disposition':
f'attachment; filename="{payload.filename}"'})
# -- publish lifecycle (geenforce.publish) ------------------------------------
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def publish_scope_route(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
from flask_jwt_extended import get_jwt_identity
notes = (request.get_json(silent=True) or {}).get('notes')
try:
publishedby = int(get_jwt_identity())
except (TypeError, ValueError):
publishedby = None
version = service.publish_scope(scope.scopename, scope.phase,
notes=notes, publishedby=publishedby)
db.session.commit()
return success_response({'versionnumber': version}, http_code=201)
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_versions(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
versions = ManifestPublishedVersion.query.filter_by(
scopeid=scopeid).order_by(
ManifestPublishedVersion.versionnumber.desc()).all()
return success_response([{
'versionnumber': v.versionnumber,
'iscurrent': v.iscurrent,
'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None,
'publishedby': v.publishedby,
'notes': v.notes,
} for v in versions])
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_version(scopeid, versionnumber):
version = ManifestPublishedVersion.query.filter_by(
scopeid=scopeid, versionnumber=versionnumber).first()
if not version:
return error_response(ErrorCodes.NOT_FOUND, 'No such version',
http_code=404)
import json as _json
return success_response({'versionnumber': version.versionnumber,
'manifest': _json.loads(version.manifestjson)})
@geenforce_bp.route('/config', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_config():
"""Plugin config: the on-share export root (for export-to-share)."""
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
return success_response({'shareroot': setting.value if setting else ''})
@geenforce_bp.route('/config', methods=['PUT'])
@jwt_required()
@require_permission('geenforce.publish')
def put_config():
payload = request.get_json(silent=True) or {}
Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(),
valuetype='string', category='geenforce',
description='On-share export root for GE-Enforce manifests')
db.session.commit()
return success_response({'shareroot': (payload.get('shareroot') or '').strip()})
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def export_share_route(scopeid):
"""Write the scope's current published JSON to the configured share root
(backing up the old file to _meta/history first)."""
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
shareroot = setting.value if setting else ''
if not shareroot:
return error_response(ErrorCodes.VALIDATION_ERROR,
'Configure the share root first', http_code=400)
try:
path = service.export_scope_to_share(scope.scopename, scope.phase,
shareroot)
except (ValueError, OSError) as exc:
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
return success_response({'path': path})
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
@jwt_required()
@require_permission('geenforce.publish')
def rollback_scope_route(scopeid):
scope = db.session.get(ManifestScope, scopeid)
if not scope:
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
versionnumber = (request.get_json(silent=True) or {}).get('versionnumber')
if versionnumber is None:
return error_response(ErrorCodes.VALIDATION_ERROR,
'versionnumber is required', http_code=400)
try:
service.rollback_scope(scope.scopename, scope.phase, int(versionnumber))
db.session.commit()
except ValueError as exc:
db.session.rollback()
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
return success_response({'versionnumber': int(versionnumber)})
def _current_published_version(scopename, phase):
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
return None
published = scope.publishedversions.filter_by(iscurrent=True).first()
return published.versionnumber if published else None
@geenforce_bp.route('/reports', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def list_reports():
"""Latest enforcement report per PC (fleet compliance view).
Each row shows the applied vs latest published version (receivedlatest = the
PC picked up the update) and the install/skip/fail counts + status.
Optional filters: hostname, scopename.
"""
query = ManifestEnforcementReport.query.filter_by(iscurrent=True)
hostname = request.args.get('hostname')
scopename = request.args.get('scopename')
if hostname:
query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname))
if scopename:
query = query.filter_by(scopename=scopename)
reports = query.order_by(
ManifestEnforcementReport.receivedat.desc()).all()
latest_cache = {}
data = []
for report in reports:
key = (report.scopename, report.phase)
if key not in latest_cache:
latest_cache[key] = _current_published_version(*key)
latest = latest_cache[key]
data.append({
'reportid': report.reportid,
'hostname': report.hostname,
'scopename': report.scopename,
'phase': report.phase,
'appliedversion': report.appliedversion,
'latestversion': latest,
'receivedlatest': (latest is not None
and report.appliedversion == latest),
'enforcerversion': report.enforcerversion,
'status': report.status,
'installed': report.installedcount,
'skipped': report.skippedcount,
'failed': report.failedcount,
'filtered': report.filteredcount,
'lastcheckin': (report.lastcheckin.isoformat() + 'Z'
if report.lastcheckin else None),
'receivedat': (report.receivedat.isoformat() + 'Z'
if report.receivedat else None),
})
return success_response(data)
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
@jwt_required()
@require_permission('geenforce.manage')
def get_report(reportid):
"""One report with per-entry outcomes (self-heal / failure detail)."""
report = db.session.get(ManifestEnforcementReport, reportid)
if not report:
return error_response(ErrorCodes.NOT_FOUND, 'No such report',
http_code=404)
latest = _current_published_version(report.scopename, report.phase)
return success_response({
'reportid': report.reportid,
'hostname': report.hostname,
'scopename': report.scopename,
'phase': report.phase,
'appliedversion': report.appliedversion,
'latestversion': latest,
'receivedlatest': (latest is not None
and report.appliedversion == latest),
'status': report.status,
'results': [{
'entryname': r.entryname,
'action': r.action,
'selfhealed': r.selfhealed,
'exitcode': r.exitcode,
'message': r.message,
} for r in report.results],
})

View File

@@ -0,0 +1,180 @@
<#
.SYNOPSIS
Provision a PC for GE-Enforce + shopdb: write its identity (so it knows its PC
type and bay), deploy the shopdb client kit, and register the enforcement
scheduled task. Site-neutral and imaging-path independent - run it from a PXE
step, an OOBE provisioning package (ppkg), Intune, or by hand.
This is the "set up the PC to determine its PC type and such" step. There is no
auto-detection: the caller supplies the type (and, where relevant, the bay
machine number and CMM version), and this script writes the files/registry the
GE-Enforce engine reads.
This script does NOT contain the GE-Enforce engine (Install-FromManifest.ps1)
or dispatcher - those are the GE-Enforce framework's. Point at your copy with
-EngineSource, or ensure it is already present under -InstallRoot\lib.
.PARAMETER PCType
The imaging PC type = the manifest scope this PC runs. One of the scope names
(e.g. gea-shopfloor-cmm, gea-shopfloor-collections) or a legacy alias the
engine maps. Written to C:\Enrollment\pc-type.txt. REQUIRED.
.PARAMETER MachineNumber
The bay machine number (for TargetMachineNumbers gates). Written to
machine-number.txt as a fallback; the DNC registry MachineNo wins if present.
.PARAMETER CmmVersion
CMM bays only: the resolved PC-DMIS version (2016/2019/2026) for _CmmVersion
gating. Written to C:\Enrollment\cmm\version.txt. Omit for non-CMM PCs.
.PARAMETER CmmId
CMM bays only: the bay id. Written to C:\Enrollment\cmm\cmmid.txt.
.PARAMETER ShareRoot
The GE-Enforce share root (UNC), written into site-config.json as
shopfloorShareRoot.
.PARAMETER Site
Site name, written into site-config.json.
.PARAMETER ShopdbUrl
shopdb base URL (scheme + host) for the fetch/report client. Written to
HKLM:\SOFTWARE\GE\ShopDB\BaseUrl.
.PARAMETER ShopdbToken
A geenforce.fetch (+ geenforce.report) managed service token. Written to
HKLM:\SOFTWARE\GE\ShopDB\ApiToken. (In many sites Azure DSC provisions this
instead - omit here if so.)
.PARAMETER EngineSource
Optional path (folder or share) containing the GE-Enforce engine to copy in:
expects GE-Enforce.ps1 and lib\Install-FromManifest.ps1. If omitted, the engine
is assumed already present under -InstallRoot.
.PARAMETER InstallRoot
Where the client kit + engine live on the PC. Default C:\ProgramData\GE-Enforce.
.PARAMETER TaskName
Scheduled task name. Default 'GE-Enforce'.
.PARAMETER IntervalMinutes
How often the enforcement task repeats. Default 5.
.PARAMETER NoTask
Provision identity + kit only; do not register the scheduled task.
.EXAMPLE
.\Install-GEEnforce.ps1 -PCType gea-shopfloor-cmm -MachineNumber 0615 `
-CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" `
-ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx `
-EngineSource \\server\share\dt\shopfloor\common
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$PCType,
[string]$MachineNumber,
[string]$CmmVersion,
[string]$CmmId,
[string]$ShareRoot,
[string]$Site,
[string]$ShopdbUrl,
[string]$ShopdbToken,
[string]$EngineSource,
[string]$InstallRoot = 'C:\ProgramData\GE-Enforce',
[string]$EnrollmentRoot = 'C:\Enrollment',
[string]$TaskName = 'GE-Enforce',
[int]$IntervalMinutes = 5,
[switch]$NoTask
)
Set-StrictMode -Version Latest
$ErrorActionPreference = 'Stop'
function Write-TextFile {
param([string]$Path, [string]$Value)
$dir = Split-Path -Parent $Path
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
Set-Content -LiteralPath $Path -Value $Value -Encoding ascii -NoNewline
Write-Host " wrote $Path"
}
try {
Write-Host "GE-Enforce provisioning: PCType=$PCType"
# --- 1. Identity: what the PC is (what the engine reads) -----------------
Write-TextFile (Join-Path $EnrollmentRoot 'pc-type.txt') $PCType
if ($MachineNumber) { Write-TextFile (Join-Path $EnrollmentRoot 'machine-number.txt') $MachineNumber }
if ($CmmVersion) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\version.txt') $CmmVersion }
if ($CmmId) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\cmmid.txt') $CmmId }
$siteConfig = @{}
if ($ShareRoot) { $siteConfig['shopfloorShareRoot'] = $ShareRoot }
if ($Site) { $siteConfig['site'] = $Site }
if ($siteConfig.Count) {
$siteConfigPath = Join-Path $EnrollmentRoot 'site-config.json'
$dir = Split-Path -Parent $siteConfigPath
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
($siteConfig | ConvertTo-Json) | Set-Content -LiteralPath $siteConfigPath -Encoding ascii
Write-Host " wrote $siteConfigPath"
}
# --- 2. shopdb client registry config ------------------------------------
if ($ShopdbUrl -or $ShopdbToken) {
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
if ($ShopdbUrl) { Set-ItemProperty -Path $regPath -Name BaseUrl -Value $ShopdbUrl }
if ($ShopdbToken) { Set-ItemProperty -Path $regPath -Name ApiToken -Value $ShopdbToken }
Write-Host " wrote HKLM:\SOFTWARE\GE\ShopDB"
}
# --- 3. Deploy the client kit (ships alongside this script) --------------
if (-not (Test-Path $InstallRoot)) { New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null }
foreach ($file in 'ShopdbEnforceClient.psm1', 'Invoke-ShopdbEnforce.ps1') {
$src = Join-Path $PSScriptRoot $file
if (Test-Path $src) {
Copy-Item -Path $src -Destination (Join-Path $InstallRoot $file) -Force
Write-Host " deployed $file"
} else {
Write-Warning "client kit file not found next to this script: $file"
}
}
# --- 4. Engine (referenced, not vendored) --------------------------------
if ($EngineSource) {
Copy-Item -Path (Join-Path $EngineSource 'GE-Enforce.ps1') `
-Destination (Join-Path $InstallRoot 'GE-Enforce.ps1') -Force -ErrorAction SilentlyContinue
$libDir = Join-Path $InstallRoot 'lib'
if (-not (Test-Path $libDir)) { New-Item -ItemType Directory -Path $libDir -Force | Out-Null }
Copy-Item -Path (Join-Path $EngineSource 'lib\Install-FromManifest.ps1') `
-Destination (Join-Path $libDir 'Install-FromManifest.ps1') -Force -ErrorAction SilentlyContinue
Write-Host " copied engine from $EngineSource"
}
$enginePath = Join-Path $InstallRoot 'lib\Install-FromManifest.ps1'
if (-not (Test-Path $enginePath)) {
Write-Warning "engine not present at $enginePath - provide -EngineSource or place it there before enforcement runs."
}
# --- 5. Scheduled task: run the enforcement client as SYSTEM -------------
if (-not $NoTask) {
$runner = Join-Path $InstallRoot 'Invoke-ShopdbEnforce.ps1'
$arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$runner`" -Scope `"$PCType`" -EnginePath `"$enginePath`""
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
$triggers = @(
(New-ScheduledTaskTrigger -AtLogOn),
(New-ScheduledTaskTrigger -Once -At (Get-Date) `
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes))
)
$principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -RunLevel Highest
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
-DontStopIfGoingOnBatteries -StartWhenAvailable
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers `
-Principal $principal -Settings $settings -Force | Out-Null
Write-Host " registered task '$TaskName' (at logon + every $IntervalMinutes min)"
}
Write-Host "GE-Enforce provisioning complete."
exit 0
} catch {
Write-Error "GE-Enforce provisioning FAILED: $($_.Exception.Message)"
exit 1
}

View File

@@ -0,0 +1,103 @@
<#
.SYNOPSIS
Reference orchestrator: source a GE-Enforce manifest from shopdb, run the
UNCHANGED engine against it, and report the result back to shopdb.
This is a thin wrapper around Install-FromManifest.ps1 (the engine). It is a
reference a site adapts into its GE-Enforce.ps1 flow; it is not the live
dispatcher. The engine, detection, self-heal, and SMB payload resolution are
untouched - only the source of the manifest JSON moves from a share file to
shopdb, plus a result report.
.PARAMETER Scope
The imaging pc-type / scope name (e.g. gea-shopfloor-cmm), same value the
dispatcher already resolves from C:\Enrollment\pc-type.txt.
.PARAMETER EnginePath
Path to Install-FromManifest.ps1 (the engine lib, >= 2.6).
.PARAMETER ShareManifestPath
The current on-share manifest for this scope. In shadow mode the engine runs
against THIS (unchanged behavior) and shopdb is only compared + reported. Once
cut over, omit it and the engine runs against the shopdb-sourced manifest.
.PARAMETER ShadowMode
Fetch + compare + report, but install from the share (no behavior change).
.NOTES
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
#>
[CmdletBinding()]
param(
[Parameter(Mandatory)] [string]$Scope,
[Parameter(Mandatory)] [string]$EnginePath,
[string]$ShareManifestPath,
[switch]$ShadowMode,
[string]$BaseUrl,
[string]$ApiToken,
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
)
function Write-Log {
param([string]$Message, [string]$Level = 'INFO')
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
try { Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue } catch {}
Write-Host $line
}
try {
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
$config = Get-ShopdbConfig -BaseUrl $BaseUrl -ApiToken $ApiToken
if (-not $config) {
Write-Log 'No shopdb BaseUrl/ApiToken configured yet - retry next cycle.' 'WARN'
exit 0
}
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
if (-not $sync.Path) {
Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN'
exit 0
}
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
# Shadow mode: compare shopdb vs the share, but install from the share.
if ($ShadowMode -and $ShareManifestPath -and (Test-Path $ShareManifestPath)) {
$diff = Compare-ShopdbShadow -ShopdbManifestPath $sync.Path -ShareManifestPath $ShareManifestPath
if ($diff.Same) {
Write-Log 'Shadow: shopdb manifest matches the share.'
} else {
Write-Log ("Shadow DIFF: shopdb-only=[{0}] share-only=[{1}] orderDiff={2}" -f `
($diff.ShopdbOnly -join ','), ($diff.ShareOnly -join ','), $diff.OrderDiff) 'WARN'
}
}
# Which manifest the engine actually runs against.
$manifestToRun = if ($ShadowMode -and $ShareManifestPath) { $ShareManifestPath } else { $sync.Path }
# --- INTEGRATION POINT ---------------------------------------------------
# Run the engine. Install-FromManifest.ps1 is expected to return (or you
# adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered
# and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this
# to your engine's actual return/parse; the shape below is the contract.
Write-Log "Running engine against $manifestToRun"
$summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
if (-not $summary) {
$summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() }
}
if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' }
# Report the result (best-effort).
$appliedVersion = 0
if ($sync.Version) { [int]::TryParse($sync.Version, [ref]$appliedVersion) | Out-Null }
$report = New-ShopdbReport -Scope $Scope -AppliedVersion $appliedVersion -Summary $summary
if (Send-ShopdbReport -Config $config -Report $report) {
Write-Log "Reported: installed=$($summary.Installed) skipped=$($summary.Skipped) failed=$($summary.Failed)."
} else {
Write-Log 'Report POST failed (non-fatal).' 'WARN'
}
} catch {
Write-Log "Unhandled error (non-fatal): $($_.Exception.Message)" 'ERROR'
}
exit 0

View File

@@ -0,0 +1,164 @@
<#
.SYNOPSIS
Client-side helpers for sourcing GE-Enforce manifests from shopdb and reporting
results back. Site-neutral reference kit - deploy alongside GE-Enforce; do NOT
hardcode any site here.
This module does NOT replace Install-FromManifest.ps1 (the engine). It only
changes where the manifest JSON comes from (shopdb HTTP instead of a share
file) and adds a result report. The engine, detection, self-heal, and SMB
payload resolution stay exactly as they are.
Resilience mirrors GE-Enforce: any failure is non-fatal (fail-safe). If shopdb
is unreachable the client enforces from the last-known-good cached manifest and
a PC is never left unmanaged because the web app is down.
Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB
BaseUrl e.g. https://shopdb.example.geaerospace.net
ApiToken a geenforce.fetch (+ geenforce.report) managed service token,
provisioned the same way as SFLD creds (Azure DSC).
#>
Set-StrictMode -Version Latest
function Get-ShopdbConfig {
param([string]$BaseUrl, [string]$ApiToken)
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) {
$props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue
if (-not $BaseUrl -and $props.BaseUrl) { $BaseUrl = $props.BaseUrl }
if (-not $ApiToken -and $props.ApiToken) { $ApiToken = $props.ApiToken }
}
if (-not $BaseUrl -or -not $ApiToken) { return $null }
return @{ BaseUrl = $BaseUrl.TrimEnd('/'); ApiToken = $ApiToken }
}
function Sync-ShopdbManifest {
<#
Fetch the current published manifest for a scope into a local cache, using
an ETag so an unchanged manifest is a cheap 304. On any network error, fall
back to the last-known-good cached copy. Returns:
@{ Path; Version; Source } where Source is
'shopdb' | 'cache-304' | 'cache-lastgood' | $null (nothing available)
#>
param(
[Parameter(Mandatory)] [string]$Scope,
[Parameter(Mandatory)] [hashtable]$Config,
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
)
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
$manifestPath = Join-Path $CacheDir "$Scope.json"
$etagPath = Join-Path $CacheDir "$Scope.etag"
$headers = @{ 'X-API-Key' = $Config.ApiToken }
if (Test-Path $etagPath) { $headers['If-None-Match'] = (Get-Content -LiteralPath $etagPath -Raw).Trim() }
$uri = "$($Config.BaseUrl)/api/geenforce/manifest?pctype=$([uri]::EscapeDataString($Scope))"
try {
$response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing `
-TimeoutSec 30 -ErrorAction Stop
if ($response.StatusCode -eq 200) {
[System.IO.File]::WriteAllText($manifestPath, $response.Content)
# PowerShell 7 returns header values as string arrays; 5.1 as scalars.
# @(...)[0] yields a clean scalar in both.
$etag = @($response.Headers['ETag'])[0]
if ($etag) {
Set-Content -LiteralPath $etagPath -Value $etag -NoNewline
}
$version = @($response.Headers['X-Manifest-Version'])[0]
if ($version) {
Set-Content -LiteralPath (Join-Path $CacheDir "$Scope.version") -Value $version -NoNewline
}
return @{ Path = $manifestPath; Version = $version; Source = 'shopdb' }
}
} catch {
$status = $null
if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
if ($status -eq 304 -and (Test-Path $manifestPath)) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
}
# Network/other failure: fall back to last-known-good.
if (Test-Path $manifestPath) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
}
return @{ Path = $null; Version = $null; Source = $null }
}
# 304 without exception (some PS versions) -> use cache.
if (Test-Path $manifestPath) {
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
}
return @{ Path = $null; Version = $null; Source = $null }
}
function Read-CachedVersion {
param([string]$CacheDir, [string]$Scope)
$verPath = Join-Path $CacheDir "$Scope.version"
if (Test-Path $verPath) { return (Get-Content -LiteralPath $verPath -Raw).Trim() }
return $null
}
function Compare-ShopdbShadow {
<#
Shadow-mode comparison: do the shopdb manifest and the on-share manifest
select the same ordered entry names? Returns @{ Same; ShopdbOnly; ShareOnly;
OrderDiff }. Behavioral, not byte, comparison.
#>
param([Parameter(Mandatory)][string]$ShopdbManifestPath,
[Parameter(Mandatory)][string]$ShareManifestPath)
$shopdb = (Get-Content -LiteralPath $ShopdbManifestPath -Raw | ConvertFrom-Json)
$share = (Get-Content -LiteralPath $ShareManifestPath -Raw | ConvertFrom-Json)
$shopdbNames = @($shopdb.Applications | ForEach-Object { $_.Name })
$shareNames = @($share.Applications | ForEach-Object { $_.Name })
return @{
Same = (($shopdbNames -join '|') -eq ($shareNames -join '|'))
ShopdbOnly = @($shopdbNames | Where-Object { $_ -notin $shareNames })
ShareOnly = @($shareNames | Where-Object { $_ -notin $shopdbNames })
OrderDiff = (($shopdbNames -join '|') -ne ($shareNames -join '|'))
}
}
function Send-ShopdbReport {
<#
POST an enforcement report to shopdb. Best-effort: never throws, returns
$true on success. Report is a hashtable matching POST /api/geenforce/report.
#>
param([Parameter(Mandatory)][hashtable]$Config,
[Parameter(Mandatory)][hashtable]$Report)
try {
$body = ($Report | ConvertTo-Json -Depth 6)
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
-Method Post -Headers @{ 'X-API-Key' = $Config.ApiToken } `
-ContentType 'application/json' -Body $body -TimeoutSec 30 -ErrorAction Stop | Out-Null
return $true
} catch {
return $false
}
}
function New-ShopdbReport {
<#
Build a report payload from an engine summary. `Summary` is expected to
carry Installed/Skipped/Failed/Filtered counts and a Results list of
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
per-entry outcomes into this at the call site.
#>
param([string]$Hostname = $env:COMPUTERNAME,
[Parameter(Mandatory)][string]$Scope,
[int]$AppliedVersion,
[Parameter(Mandatory)][hashtable]$Summary)
return @{
hostname = $Hostname
scopename = $Scope
appliedversion = $AppliedVersion
enforcerversion = $Summary.EnforcerVersion
counts = @{
installed = [int]$Summary.Installed
skipped = [int]$Summary.Skipped
failed = [int]$Summary.Failed
filtered = [int]$Summary.Filtered
}
results = @($Summary.Results)
}
}
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion

View File

@@ -0,0 +1,144 @@
"""Python mirror of the GE-Enforce engine's targeting filters.
These re-implement, field for field, the four filter functions in
`Install-FromManifest.ps1` (Test-PCTypeMatches, Test-HostnameMatches,
Test-MachineNumberMatches, Test-CmmVersionMatches) plus the PCTypes alias graph
(lines 463-475). They operate on plain manifest-entry dicts (the exact shape of
an Applications[] entry) and a machine-profile dict, so the SAME code answers
two questions:
1. Parity harness: do the on-share manifest and the shopdb-rebuilt manifest
select the same entries in the same order for a set of machine profiles?
2. Simulator: "what would PC Y get" - which entries apply and why.
The engine lib stays the single source of truth; this is a mirror. If the lib's
alias map changes, ALIAS_GROUPS here must change with it (the parity harness's
legacy-name profiles fail loudly on divergence).
"""
import fnmatch
# Mirror of $script:_pcTypeAliasGroups in Install-FromManifest.ps1:463-475.
# Rows are alias sets: names in the same set all match the same identity.
ALIAS_GROUPS = [
('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections',
'gea-shopfloor-common'),
('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'),
('Standard-Timeclock', 'gea-shopfloor-common'),
('CMM', 'gea-shopfloor-cmm'),
('Keyence', 'gea-shopfloor-keyence'),
('Lab', 'gea-shopfloor-common'),
('WaxAndTrace', 'gea-shopfloor-waxtrace'),
('Genspect', 'gea-shopfloor-genspect'),
('Display', 'gea-shopfloor-display'),
('Heattreat', 'gea-shopfloor-heattreat'),
('PartMarker', 'gea-shopfloor-partmarker'),
]
def _alias_sets(name):
"""Every alias group containing `name` (case-insensitive)."""
lname = (name or '').lower()
return [g for g in ALIAS_GROUPS if any(n.lower() == lname for n in g)]
def matches_pctype(entry, pctype, subtype=None, strict_allowed=False):
"""Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection.
PCTypesStrict=true disables alias expansion (both the PC's identity and the
manifest values are compared literally), so a 'collections'-only strict entry
does NOT match a 'nocollections' PC via their shared 'Standard' alias group.
IMPORTANT: only the PREINSTALL runner honors this flag - the runtime engine
(Install-FromManifest.ps1's Test-PCTypeMatches) has no strict handling. So
strict is applied only when strict_allowed (i.e. phase == 'preinstall'),
else a runtime entry with PCTypesStrict still alias-expands, matching the
real engine.
"""
values = entry.get('PCTypes') or []
if not values:
return True
if not pctype:
return True
strict = strict_allowed and bool(entry.get('PCTypesStrict'))
# Names the current PC matches: bare type, "type-subtype", and (unless strict)
# all aliases of either.
mynames = set()
mynames.add(pctype.lower())
seeds = [pctype]
if subtype:
combined = f'{pctype}-{subtype}'
mynames.add(combined.lower())
seeds.append(combined)
if not strict:
for seed in seeds:
for group in _alias_sets(seed):
for alias in group:
mynames.add(alias.lower())
for value in values:
if value == '*':
return True
if value.lower() in mynames:
return True
if strict:
continue
# The manifest value may itself be an alias - expand and check overlap.
for group in _alias_sets(value):
for alias in group:
if alias.lower() in mynames:
return True
return False
def matches_hostname(entry, hostname):
"""Test-HostnameMatches: exact or PowerShell -like glob (*, ?, [set])."""
patterns = entry.get('TargetHostnames') or []
if not patterns:
return True
myname = (hostname or '').lower()
for pattern in patterns:
lpattern = (pattern or '').lower()
if lpattern == myname:
return True
if fnmatch.fnmatch(myname, lpattern):
return True
return False
def matches_machinenumber(entry, machinenumber):
"""Test-MachineNumberMatches: filter present but PC has no number = exclude."""
numbers = entry.get('TargetMachineNumbers') or []
if not numbers:
return True
if not machinenumber:
return False
mynumber = str(machinenumber).lower()
return any(str(n).lower() == mynumber for n in numbers)
def matches_cmmversion(entry, cmmversion):
"""Test-CmmVersionMatches: untagged always applies; no PC version = install-all."""
tag = entry.get('_CmmVersion')
if not tag:
return True
if not cmmversion:
return True
return str(tag).lower() == str(cmmversion).lower()
def entry_applies(entry, profile):
"""All four filters ANDed, exactly as the engine's main loop applies them.
`profile` keys: pctype, subtype, hostname, machinenumber, cmmversion, and
optional phase ('preinstall' enables PCTypesStrict, matching the runners).
"""
strict_allowed = profile.get('phase') == 'preinstall'
return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype'),
strict_allowed)
and matches_hostname(entry, profile.get('hostname'))
and matches_machinenumber(entry, profile.get('machinenumber'))
and matches_cmmversion(entry, profile.get('cmmversion')))
def applicable_entry_names(applications, profile):
"""Ordered list of entry Names that pass all filters for a profile."""
return [e.get('Name') for e in applications if entry_applies(e, profile)]

View File

@@ -0,0 +1,117 @@
"""Parse on-share manifest.json / preinstall.json into draft DB rows.
The reverse of serializer.py. `build_scope` turns a parsed manifest dict into a
ManifestScope with its ordered entries and child rows (not yet committed).
`discover_share` walks a GE-Enforce share root and yields (scopename, phase,
manifest_dict) for common + every gea-shopfloor-* runtime manifest, skipping
`.bak` variants. `load_preinstall` reads a preinstall.json.
Import is a rebuild: the caller replaces a scope's draft rows so re-running is
idempotent.
"""
import json
import os
from .models import (
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
)
from .serializer import _SCALAR_FIELDS, _BOOL_FLAGS
# manifest key -> model attr (reverse of the serializer's scalar map).
_KEY_TO_ATTR = {mk: attr for attr, mk in _SCALAR_FIELDS}
_FLAG_TO_ATTR = {mk: attr for attr, mk in _BOOL_FLAGS}
# Scalar model attributes that a populate resets (so an update clears fields the
# new payload omits). name/entrytype are required and set explicitly.
_RESETTABLE_ATTRS = [attr for _, attr in _SCALAR_FIELDS
if attr not in ('name', 'entrytype')] + ['comment', 'regvalue']
def populate_entry(entry, entry_dict):
"""Set every field + child on an existing (or new) ManifestEntry from one
Applications[] entry dict. Existing children are replaced. Returns entry."""
entry.name = entry_dict.get('Name')
entry.entrytype = entry_dict.get('Type')
entry.comment = entry_dict.get('_comment')
for attr in _RESETTABLE_ATTRS:
if attr in ('comment', 'regvalue'):
continue
setattr(entry, attr, None)
for key, attr in _KEY_TO_ATTR.items():
if attr in ('name', 'entrytype'):
continue
if key in entry_dict and entry_dict[key] is not None:
setattr(entry, attr, entry_dict[key])
# RegValue stored as its raw JSON literal so DWord vs string typing survives.
entry.regvalue = (json.dumps(entry_dict['RegValue'])
if 'RegValue' in entry_dict else None)
for key, attr in _FLAG_TO_ATTR.items():
setattr(entry, attr, bool(entry_dict.get(key)))
# Multi-value filters -> child rows (replace, preserve order).
entry.pctypes = [ManifestEntryPcType(sortorder=i, pctypevalue=value)
for i, value in enumerate(entry_dict.get('PCTypes') or [])]
entry.hostnames = [ManifestEntryHostname(sortorder=i, hostnamepattern=value)
for i, value in enumerate(entry_dict.get('TargetHostnames') or [])]
entry.machinenumbers = [
ManifestEntryMachineNumber(sortorder=i, machinenumber=str(value))
for i, value in enumerate(entry_dict.get('TargetMachineNumbers') or [])]
# InUseCheck object + Processes[].
inuse = entry_dict.get('InUseCheck')
if inuse:
check = ManifestInUseCheck(behavior=inuse.get('Behavior'))
for i, proc in enumerate(inuse.get('Processes') or []):
check.processes.append(ManifestInUseCheckProcess(
sortorder=i,
processname=proc.get('Name'),
exepath=proc.get('ExePath'),
gracefulclosetimeoutsec=proc.get('GracefulCloseTimeoutSec')))
entry.inusecheck = check
else:
entry.inusecheck = None
return entry
def build_entry(entry_dict, sortorder):
"""Build a new ManifestEntry (+ children) from one Applications[] entry."""
entry = ManifestEntry(sortorder=sortorder)
return populate_entry(entry, entry_dict)
def build_scope(scopename, phase, manifest_dict, iscommon=False):
"""Build a ManifestScope (+ entries) from a parsed manifest dict."""
scope = ManifestScope(
scopename=scopename,
phase=phase,
manifestversion=str(manifest_dict.get('Version', '1.0')),
topcomment=manifest_dict.get('_comment'),
site=manifest_dict.get('Site'),
iscommon=iscommon)
for i, entry_dict in enumerate(manifest_dict.get('Applications') or []):
scope.entries.append(build_entry(entry_dict, i))
return scope
def discover_share(shareroot):
"""Yield (scopename, phase, manifest_dict) for runtime manifests on a share.
Reads `common/manifest.json` and every `gea-shopfloor-*/manifest.json`.
Skips `.bak` / `.pre-*.bak` variants (only the exact `manifest.json`).
"""
for name in sorted(os.listdir(shareroot)):
if name != 'common' and not name.startswith('gea-shopfloor-'):
continue
path = os.path.join(shareroot, name, 'manifest.json')
if not os.path.isfile(path):
continue
with open(path) as handle:
manifest = json.load(handle)
yield name, 'runtime', manifest
def load_manifest_file(path):
"""Parse a single manifest.json / preinstall.json file."""
with open(path) as handle:
return json.load(handle)

View File

@@ -0,0 +1,14 @@
{
"name": "geenforce",
"display_name": "GE-Enforce",
"version": "0.1.0",
"description": "GE-Enforce manifest store. Owns imaging PC-type scopes and their install manifests (apps, scripts, files, registry, version gates) as shopdb data, served to the GE-Enforce client as JSON. Requires GE-Enforce lib >= 2.6 on target PCs (the _CmmVersion gate).",
"author": "ShopDB Team",
"dependencies": [],
"core_version": ">=0.7.0,<1.0.0",
"api_prefix": "/api/geenforce",
"default_enabled": false,
"provides": {
"features": ["manifest-store", "imaging-pc-types"]
}
}

View File

@@ -0,0 +1,14 @@
"""Alembic environment for the GE-Enforce plugin migration chain.
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
the metadata to this plugin's tables and drives Alembic against the per-plugin
version table alembic_version_geenforce (ADR-008). This plugin is NEW: its 0001
baseline really CREATES its tables, because the core chain never built them.
"""
import os
os.environ['PLUGIN_NAME'] = 'geenforce'
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
run_migrations()

View File

@@ -0,0 +1,24 @@
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic.
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade():
${upgrades if upgrades else "pass"}
def downgrade():
${downgrades if downgrades else "pass"}

View File

@@ -0,0 +1,241 @@
"""geenforce plugin baseline (real create).
This plugin was built after the ADR-008 ownership cutover, so this baseline
actually CREATES the plugin's tables (the core chain never knew about them). It
runs from `flask plugin install geenforce` / `flask plugin upgrade-all` after
`flask db upgrade` builds the core schema. All foreign keys are intra-plugin, so
tables are created parent-first. computertypeid / measuringtooltypeid /
publishedby are SOFT references (plain columns, no FK) to optional plugins/core.
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = 'geenforce0001baseline'
down_revision = None
branch_labels = None
depends_on = None
def upgrade():
op.create_table(
'manifestscopes',
sa.Column('scopeid', sa.Integer(), nullable=False),
sa.Column('scopename', sa.String(length=64), nullable=False),
sa.Column('phase', sa.String(length=16), nullable=False),
sa.Column('computertypeid', sa.Integer(), nullable=True),
sa.Column('measuringtooltypeid', sa.Integer(), nullable=True),
sa.Column('manifestversion', sa.String(length=16), nullable=False),
sa.Column('description', sa.String(length=255), nullable=True),
sa.Column('topcomment', sa.Text(), nullable=True),
sa.Column('site', sa.String(length=100), nullable=True),
sa.Column('iscommon', sa.Boolean(), nullable=False),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('scopeid'),
sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
)
op.create_table(
'manifestentries',
sa.Column('entryid', sa.Integer(), nullable=False),
sa.Column('scopeid', sa.Integer(), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False),
sa.Column('name', sa.String(length=128), nullable=False),
sa.Column('entrytype', sa.String(length=16), nullable=False),
sa.Column('comment', sa.Text(), nullable=True),
sa.Column('installer', sa.String(length=255), nullable=True),
sa.Column('installargs', sa.Text(), nullable=True),
sa.Column('scriptpath', sa.String(length=255), nullable=True),
sa.Column('scriptargs', sa.String(length=255), nullable=True),
sa.Column('sourcepath', sa.String(length=255), nullable=True),
sa.Column('destination', sa.String(length=255), nullable=True),
sa.Column('regpath', sa.String(length=255), nullable=True),
sa.Column('regname', sa.String(length=128), nullable=True),
sa.Column('regvalue', sa.Text(), nullable=True),
sa.Column('regtype', sa.String(length=16), nullable=True),
sa.Column('payloadsource', sa.String(length=8), nullable=False),
sa.Column('payloadref', sa.String(length=512), nullable=True),
sa.Column('payloadsha256', sa.String(length=64), nullable=True),
sa.Column('detectionmethod', sa.String(length=16), nullable=True),
sa.Column('detectionpath', sa.String(length=255), nullable=True),
sa.Column('detectionname', sa.String(length=128), nullable=True),
sa.Column('detectionvalue', sa.String(length=255), nullable=True),
sa.Column('detectionpattern', sa.String(length=255), nullable=True),
sa.Column('cmmversion', sa.String(length=16), nullable=True),
sa.Column('logfile', sa.String(length=255), nullable=True),
sa.Column('waittimeoutsec', sa.Integer(), nullable=True),
sa.Column('applymode', sa.String(length=16), nullable=True),
sa.Column('updatewindow', sa.String(length=11), nullable=True),
sa.Column('appid', sa.Integer(), nullable=True),
sa.Column('preenrollment', sa.Boolean(), nullable=False),
sa.Column('killafterdetection', sa.Boolean(), nullable=False),
sa.Column('pctypesstrict', sa.Boolean(), nullable=False),
sa.Column('createddate', sa.DateTime(), nullable=False),
sa.Column('modifieddate', sa.DateTime(), nullable=False),
sa.Column('isactive', sa.Boolean(), nullable=False),
sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('entryid'),
sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
)
op.create_index('idx_entry_scope', 'manifestentries', ['scopeid'])
op.create_index('idx_entry_scope_order', 'manifestentries',
['scopeid', 'sortorder'])
op.create_table(
'manifestentrypctypes',
sa.Column('entrypctypeid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False),
sa.Column('pctypevalue', sa.String(length=64), nullable=False),
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('entrypctypeid'),
)
op.create_index('idx_pctype_entry', 'manifestentrypctypes', ['entryid'])
op.create_table(
'manifestentryhostnames',
sa.Column('entryhostnameid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False),
sa.Column('hostnamepattern', sa.String(length=64), nullable=False),
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('entryhostnameid'),
)
op.create_index('idx_hostname_entry', 'manifestentryhostnames', ['entryid'])
op.create_table(
'manifestentrymachinenumbers',
sa.Column('entrymachinenumberid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False),
sa.Column('machinenumber', sa.String(length=16), nullable=False),
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('entrymachinenumberid'),
)
op.create_index('idx_machinenumber_entry', 'manifestentrymachinenumbers',
['entryid'])
op.create_table(
'manifestinusechecks',
sa.Column('inusecheckid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
sa.Column('behavior', sa.String(length=20), nullable=False),
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('inusecheckid'),
sa.UniqueConstraint('entryid'),
)
op.create_table(
'manifestinusecheckprocesses',
sa.Column('inusecheckprocessid', sa.Integer(), nullable=False),
sa.Column('inusecheckid', sa.Integer(), nullable=False),
sa.Column('sortorder', sa.Integer(), nullable=False),
sa.Column('processname', sa.String(length=64), nullable=False),
sa.Column('exepath', sa.String(length=255), nullable=True),
sa.Column('gracefulclosetimeoutsec', sa.Integer(), nullable=True),
sa.ForeignKeyConstraint(['inusecheckid'],
['manifestinusechecks.inusecheckid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('inusecheckprocessid'),
)
op.create_index('idx_process_check', 'manifestinusecheckprocesses',
['inusecheckid'])
op.create_table(
'manifestpublishedversions',
sa.Column('publishedversionid', sa.Integer(), nullable=False),
sa.Column('scopeid', sa.Integer(), nullable=False),
sa.Column('versionnumber', sa.Integer(), nullable=False),
sa.Column('manifestjson', sa.Text(length=16777215), nullable=False),
sa.Column('publishedat', sa.DateTime(), nullable=False),
sa.Column('publishedby', sa.Integer(), nullable=True),
sa.Column('iscurrent', sa.Boolean(), nullable=False),
sa.Column('notes', sa.String(length=255), nullable=True),
sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('publishedversionid'),
sa.UniqueConstraint('scopeid', 'versionnumber',
name='uq_published_scope_version'),
)
op.create_index('idx_published_scope', 'manifestpublishedversions',
['scopeid'])
op.create_table(
'manifestpayloads',
sa.Column('payloadid', sa.Integer(), nullable=False),
sa.Column('entryid', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('contenttype', sa.String(length=128), nullable=True),
sa.Column('payloadbytes', sa.LargeBinary(length=16777215), nullable=False),
sa.Column('payloadsha256', sa.String(length=64), nullable=False),
sa.Column('uploadedat', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('payloadid'),
)
op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid'])
op.create_table(
'manifestenforcementreports',
sa.Column('reportid', sa.Integer(), nullable=False),
sa.Column('hostname', sa.String(length=100), nullable=False),
sa.Column('scopename', sa.String(length=64), nullable=False),
sa.Column('phase', sa.String(length=16), nullable=False),
sa.Column('appliedversion', sa.Integer(), nullable=True),
sa.Column('enforcerversion', sa.String(length=20), nullable=True),
sa.Column('installedcount', sa.Integer(), nullable=False),
sa.Column('skippedcount', sa.Integer(), nullable=False),
sa.Column('failedcount', sa.Integer(), nullable=False),
sa.Column('filteredcount', sa.Integer(), nullable=False),
sa.Column('status', sa.String(length=16), nullable=False),
sa.Column('lastcheckin', sa.DateTime(), nullable=True),
sa.Column('receivedat', sa.DateTime(), nullable=False),
sa.Column('iscurrent', sa.Boolean(), nullable=False),
sa.PrimaryKeyConstraint('reportid'),
)
op.create_index('idx_report_host', 'manifestenforcementreports',
['hostname'])
op.create_index('idx_report_current', 'manifestenforcementreports',
['iscurrent'])
op.create_index('idx_report_host_scope', 'manifestenforcementreports',
['hostname', 'scopename', 'phase'])
op.create_table(
'manifestenforcementresults',
sa.Column('resultid', sa.Integer(), nullable=False),
sa.Column('reportid', sa.Integer(), nullable=False),
sa.Column('entryname', sa.String(length=128), nullable=False),
sa.Column('action', sa.String(length=16), nullable=False),
sa.Column('selfhealed', sa.Boolean(), nullable=False),
sa.Column('exitcode', sa.Integer(), nullable=True),
sa.Column('message', sa.Text(), nullable=True),
sa.ForeignKeyConstraint(['reportid'],
['manifestenforcementreports.reportid'],
ondelete='CASCADE'),
sa.PrimaryKeyConstraint('resultid'),
)
op.create_index('idx_result_report', 'manifestenforcementresults',
['reportid'])
op.create_table(
'pctypealiases',
sa.Column('aliasid', sa.Integer(), nullable=False),
sa.Column('aliasgroup', sa.Integer(), nullable=False),
sa.Column('aliasname', sa.String(length=64), nullable=False),
sa.PrimaryKeyConstraint('aliasid'),
sa.UniqueConstraint('aliasgroup', 'aliasname',
name='uq_alias_group_name'),
)
op.create_index('idx_alias_group', 'pctypealiases', ['aliasgroup'])
def downgrade():
op.drop_table('pctypealiases')
op.drop_table('manifestenforcementresults')
op.drop_table('manifestenforcementreports')
op.drop_table('manifestpayloads')
op.drop_table('manifestpublishedversions')
op.drop_table('manifestinusecheckprocesses')
op.drop_table('manifestinusechecks')
op.drop_table('manifestentrymachinenumbers')
op.drop_table('manifestentryhostnames')
op.drop_table('manifestentrypctypes')
op.drop_table('manifestentries')
op.drop_table('manifestscopes')

View File

@@ -0,0 +1,45 @@
"""GE-Enforce plugin models."""
from .manifest import (
ManifestScope,
ManifestEntry,
ManifestEntryPcType,
ManifestEntryHostname,
ManifestEntryMachineNumber,
ManifestInUseCheck,
ManifestInUseCheckProcess,
ManifestPublishedVersion,
ManifestPayload,
ManifestEnforcementReport,
ManifestEnforcementResult,
PcTypeAlias,
PHASES,
ENTRY_TYPES,
REG_TYPES,
PAYLOAD_SOURCES,
DETECTION_METHODS,
APPLY_MODES,
INUSE_BEHAVIORS,
)
__all__ = [
'ManifestScope',
'ManifestEntry',
'ManifestEntryPcType',
'ManifestEntryHostname',
'ManifestEntryMachineNumber',
'ManifestInUseCheck',
'ManifestInUseCheckProcess',
'ManifestPublishedVersion',
'ManifestPayload',
'ManifestEnforcementReport',
'ManifestEnforcementResult',
'PcTypeAlias',
'PHASES',
'ENTRY_TYPES',
'REG_TYPES',
'PAYLOAD_SOURCES',
'DETECTION_METHODS',
'APPLY_MODES',
'INUSE_BEHAVIORS',
]

View File

@@ -0,0 +1,353 @@
"""GE-Enforce manifest-store models.
The manifest that GE-Enforce runs on each PC becomes shopdb data. A
`ManifestScope` is one imaging PC type (a `gea-shopfloor-*` scope, the `common`
fleet-wide scope, or the single flat `preinstall` scope). Each scope owns an
ordered list of `ManifestEntry` rows - one per Applications[] entry in the
on-share manifest.json - plus child rows for the multi-value targeting filters
and the nested InUseCheck.
Design choices (see the ge-enforce-plugin proposal in the project wiki):
- ONE wide entries table with an `entrytype` discriminator and nullable
per-type columns. Not STI, not a JSON blob: the whole fleet is ~64 entries,
so sparse columns are free and every field stays queryable + plain-SQL
readable by IT.
- Array order IS execution order, so `sortorder` is a real column and the
contract; config-restore entries sit after their installer on purpose.
- `computertypeid` / `measuringtooltypeid` are SOFT references (plain Integer,
no DB foreign key) to other plugins' tables, which may not be installed.
- Published manifests are immutable snapshots that freeze the rendered JSON
document (`ManifestPublishedVersion`); the draft (`ManifestEntry`) is never
served to a client.
"""
from shopdb.api import db, BaseModel
# Allowed enumerations, validated in the API/service layer (stored as strings
# for portability, matching the rest of the codebase).
PHASES = ('runtime', 'preinstall')
ENTRY_TYPES = ('MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry')
REG_TYPES = ('String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary')
PAYLOAD_SOURCES = ('smb', 'http', 'inline')
DETECTION_METHODS = ('Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
'ValueMatches', 'pnputil', 'Always')
APPLY_MODES = ('Nightly', 'Immediate', 'ImmediateReboot')
INUSE_BEHAVIORS = ('Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot')
class ManifestScope(BaseModel):
"""One imaging PC type / manifest scope (the working/draft head)."""
__tablename__ = 'manifestscopes'
scopeid = db.Column(db.Integer, primary_key=True)
# 'common', 'gea-shopfloor-cmm', 'preinstall', ...
scopename = db.Column(db.String(64), nullable=False)
# 'runtime' (per-pctype manifest.json) or 'preinstall' (one flat manifest)
phase = db.Column(db.String(16), nullable=False, default='runtime')
# Soft refs (no FK): the plugins that own these tables are optional.
computertypeid = db.Column(db.Integer, nullable=True)
measuringtooltypeid = db.Column(db.Integer, nullable=True)
manifestversion = db.Column(db.String(16), nullable=False, default='1.0')
description = db.Column(db.String(255), nullable=True)
# The manifest-level '_comment' (documentation), preserved verbatim so
# export-to-share round-trips it. Excluded from behavioral parity.
topcomment = db.Column(db.Text, nullable=True)
iscommon = db.Column(db.Boolean, nullable=False, default=False)
# Preinstall manifests carry a top-level 'Site' field.
site = db.Column(db.String(100), nullable=True)
entries = db.relationship(
'ManifestEntry', back_populates='scope',
cascade='all, delete-orphan', order_by='ManifestEntry.sortorder',
lazy='selectin')
publishedversions = db.relationship(
'ManifestPublishedVersion', back_populates='scope',
cascade='all, delete-orphan',
order_by='ManifestPublishedVersion.versionnumber', lazy='dynamic')
__table_args__ = (
db.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
)
def __repr__(self):
return f"<ManifestScope {self.scopename}/{self.phase}>"
class ManifestEntry(BaseModel):
"""One Applications[] entry (draft). Wide table, entrytype discriminator."""
__tablename__ = 'manifestentries'
entryid = db.Column(db.Integer, primary_key=True)
scopeid = db.Column(
db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
nullable=False, index=True)
sortorder = db.Column(db.Integer, nullable=False, default=0)
name = db.Column(db.String(128), nullable=False)
entrytype = db.Column(db.String(16), nullable=False)
comment = db.Column(db.Text, nullable=True) # manifest '_comment'
# Type-specific payload references (nullable; validated per entrytype).
installer = db.Column(db.String(255), nullable=True)
installargs = db.Column(db.Text, nullable=True) # can run long
scriptpath = db.Column(db.String(255), nullable=True) # 'Script'
scriptargs = db.Column(db.String(255), nullable=True) # 'Args'
sourcepath = db.Column(db.String(255), nullable=True) # 'Source'
destination = db.Column(db.String(255), nullable=True)
regpath = db.Column(db.String(255), nullable=True)
regname = db.Column(db.String(128), nullable=True)
# Raw JSON literal preserved verbatim (1 vs "1"); DWord typing depends on it.
regvalue = db.Column(db.Text, nullable=True)
regtype = db.Column(db.String(16), nullable=True)
# Payload transport + integrity (integrity hash is SEPARATE from detection).
payloadsource = db.Column(db.String(8), nullable=False, default='smb')
payloadref = db.Column(db.String(512), nullable=True)
payloadsha256 = db.Column(db.String(64), nullable=True)
# Detection (decides whether the action fires / self-heals).
detectionmethod = db.Column(db.String(16), nullable=True)
detectionpath = db.Column(db.String(255), nullable=True)
detectionname = db.Column(db.String(128), nullable=True)
detectionvalue = db.Column(db.String(255), nullable=True)
detectionpattern = db.Column(db.String(255), nullable=True)
# Gates + control.
cmmversion = db.Column(db.String(16), nullable=True) # '_CmmVersion'
logfile = db.Column(db.String(255), nullable=True)
waittimeoutsec = db.Column(db.Integer, nullable=True)
applymode = db.Column(db.String(16), nullable=True) # inert in engine today
updatewindow = db.Column(db.String(11), nullable=True) # 'HH:MM-HH:MM', inert
# Optional CURATED link to a core Applications catalog row (soft ref, no FK
# to keep the plugin decoupled). shopdb metadata only - NOT part of the
# manifest JSON the engine reads, so it never affects enforcement or parity.
appid = db.Column(db.Integer, nullable=True)
# Preinstall-only flags.
preenrollment = db.Column(db.Boolean, nullable=False, default=False)
killafterdetection = db.Column(db.Boolean, nullable=False, default=False)
pctypesstrict = db.Column(db.Boolean, nullable=False, default=False)
scope = db.relationship('ManifestScope', back_populates='entries')
pctypes = db.relationship(
'ManifestEntryPcType', back_populates='entry',
cascade='all, delete-orphan', order_by='ManifestEntryPcType.sortorder',
lazy='selectin')
hostnames = db.relationship(
'ManifestEntryHostname', back_populates='entry',
cascade='all, delete-orphan', order_by='ManifestEntryHostname.sortorder',
lazy='selectin')
machinenumbers = db.relationship(
'ManifestEntryMachineNumber', back_populates='entry',
cascade='all, delete-orphan',
order_by='ManifestEntryMachineNumber.sortorder', lazy='selectin')
inusecheck = db.relationship(
'ManifestInUseCheck', back_populates='entry', uselist=False,
cascade='all, delete-orphan', lazy='selectin')
__table_args__ = (
db.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
db.Index('idx_entry_scope_order', 'scopeid', 'sortorder'),
)
def __repr__(self):
return f"<ManifestEntry {self.name} ({self.entrytype})>"
class ManifestEntryPcType(db.Model):
"""One value of an entry's PCTypes filter (verbatim, incl. '*' and aliases)."""
__tablename__ = 'manifestentrypctypes'
entrypctypeid = db.Column(db.Integer, primary_key=True)
entryid = db.Column(
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
nullable=False, index=True)
sortorder = db.Column(db.Integer, nullable=False, default=0)
pctypevalue = db.Column(db.String(64), nullable=False)
entry = db.relationship('ManifestEntry', back_populates='pctypes')
class ManifestEntryHostname(db.Model):
"""One value of an entry's TargetHostnames filter (wildcards kept verbatim)."""
__tablename__ = 'manifestentryhostnames'
entryhostnameid = db.Column(db.Integer, primary_key=True)
entryid = db.Column(
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
nullable=False, index=True)
sortorder = db.Column(db.Integer, nullable=False, default=0)
hostnamepattern = db.Column(db.String(64), nullable=False)
entry = db.relationship('ManifestEntry', back_populates='hostnames')
class ManifestEntryMachineNumber(db.Model):
"""One value of an entry's TargetMachineNumbers filter."""
__tablename__ = 'manifestentrymachinenumbers'
entrymachinenumberid = db.Column(db.Integer, primary_key=True)
entryid = db.Column(
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
nullable=False, index=True)
sortorder = db.Column(db.Integer, nullable=False, default=0)
machinenumber = db.Column(db.String(16), nullable=False)
entry = db.relationship('ManifestEntry', back_populates='machinenumbers')
class ManifestInUseCheck(db.Model):
"""The InUseCheck object on an entry (0..1), with its Processes[] children."""
__tablename__ = 'manifestinusechecks'
inusecheckid = db.Column(db.Integer, primary_key=True)
entryid = db.Column(
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
nullable=False, unique=True)
behavior = db.Column(db.String(20), nullable=False)
entry = db.relationship('ManifestEntry', back_populates='inusecheck')
processes = db.relationship(
'ManifestInUseCheckProcess', back_populates='inusecheck',
cascade='all, delete-orphan',
order_by='ManifestInUseCheckProcess.sortorder', lazy='selectin')
class ManifestInUseCheckProcess(db.Model):
"""One process in an InUseCheck's Processes[] list."""
__tablename__ = 'manifestinusecheckprocesses'
inusecheckprocessid = db.Column(db.Integer, primary_key=True)
inusecheckid = db.Column(
db.Integer,
db.ForeignKey('manifestinusechecks.inusecheckid', ondelete='CASCADE'),
nullable=False, index=True)
sortorder = db.Column(db.Integer, nullable=False, default=0)
processname = db.Column(db.String(64), nullable=False)
exepath = db.Column(db.String(255), nullable=True)
# Null = engine default (10); do not bake the default into the row.
gracefulclosetimeoutsec = db.Column(db.Integer, nullable=True)
inusecheck = db.relationship('ManifestInUseCheck', back_populates='processes')
class ManifestPublishedVersion(db.Model):
"""Immutable published snapshot: the frozen rendered JSON document.
The client is always served the `iscurrent` snapshot for a scope, never the
live draft. Rollback flips `iscurrent`. Freezing the text (not row-mirroring)
makes immutability structural and rollback a one-flag change.
"""
__tablename__ = 'manifestpublishedversions'
publishedversionid = db.Column(db.Integer, primary_key=True)
scopeid = db.Column(
db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
nullable=False, index=True)
versionnumber = db.Column(db.Integer, nullable=False)
# MEDIUMTEXT on MySQL (a full scope with comments can exceed 64 KB TEXT).
manifestjson = db.Column(db.Text(length=16777215), nullable=False)
publishedat = db.Column(db.DateTime, nullable=False)
publishedby = db.Column(db.Integer, nullable=True) # soft ref to users
iscurrent = db.Column(db.Boolean, nullable=False, default=False)
notes = db.Column(db.String(255), nullable=True)
scope = db.relationship('ManifestScope', back_populates='publishedversions')
__table_args__ = (
db.UniqueConstraint('scopeid', 'versionnumber',
name='uq_published_scope_version'),
)
class ManifestPayload(db.Model):
"""Inline payload bytes for payloadsource='inline' (small scripts/configs)."""
__tablename__ = 'manifestpayloads'
payloadid = db.Column(db.Integer, primary_key=True)
entryid = db.Column(
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
nullable=False, index=True)
filename = db.Column(db.String(255), nullable=False)
contenttype = db.Column(db.String(128), nullable=True)
payloadbytes = db.Column(db.LargeBinary(length=16777215), nullable=False)
payloadsha256 = db.Column(db.String(64), nullable=False)
uploadedat = db.Column(db.DateTime, nullable=False)
class ManifestEnforcementReport(db.Model):
"""One enforcement cycle reported by a PC (observed state).
Each cycle the client POSTs its result for a scope: the published version it
actually applied (so shopdb knows whether the PC RECEIVED the latest update),
the enforcer version, and the installed/skipped/failed/filtered counts. The
latest report per (hostname, scopename, phase) carries `iscurrent`; older
ones are history. Pairs desired state (the manifest) with observed state.
"""
__tablename__ = 'manifestenforcementreports'
reportid = db.Column(db.Integer, primary_key=True)
hostname = db.Column(db.String(100), nullable=False, index=True)
scopename = db.Column(db.String(64), nullable=False)
phase = db.Column(db.String(16), nullable=False, default='runtime')
# Published version the client actually ran; compare to the scope's current
# published version to see whether this PC received the latest manifest.
appliedversion = db.Column(db.Integer, nullable=True)
enforcerversion = db.Column(db.String(20), nullable=True)
installedcount = db.Column(db.Integer, nullable=False, default=0)
skippedcount = db.Column(db.Integer, nullable=False, default=0)
failedcount = db.Column(db.Integer, nullable=False, default=0)
filteredcount = db.Column(db.Integer, nullable=False, default=0)
# 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure).
status = db.Column(db.String(16), nullable=False, default='ok')
lastcheckin = db.Column(db.DateTime, nullable=True) # PC-reported time
receivedat = db.Column(db.DateTime, nullable=False) # server time
iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True)
results = db.relationship(
'ManifestEnforcementResult', back_populates='report',
cascade='all, delete-orphan', lazy='selectin')
__table_args__ = (
db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'),
)
class ManifestEnforcementResult(db.Model):
"""One entry's outcome within an enforcement cycle (self-heal detail)."""
__tablename__ = 'manifestenforcementresults'
resultid = db.Column(db.Integer, primary_key=True)
reportid = db.Column(
db.Integer,
db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'),
nullable=False, index=True)
entryname = db.Column(db.String(128), nullable=False)
# 'installed' (action fired - a self-heal when it should already be present),
# 'skipped' (detected present), 'failed', 'filtered'.
action = db.Column(db.String(16), nullable=False)
# True when this install was a drift correction (self-heal), not a first
# install. Client-supplied; defaults to whether the action installed.
selfhealed = db.Column(db.Boolean, nullable=False, default=False)
exitcode = db.Column(db.Integer, nullable=True)
message = db.Column(db.Text, nullable=True) # warning / error text
report = db.relationship('ManifestEnforcementReport', back_populates='results')
class PcTypeAlias(db.Model):
"""Mirror of the engine lib's PCTypes alias graph (Install-FromManifest.ps1).
Rows sharing an `aliasgroup` are one alias set. Server-side resolve/validate
only; the engine lib stays the single source of truth (never inverted).
"""
__tablename__ = 'pctypealiases'
aliasid = db.Column(db.Integer, primary_key=True)
aliasgroup = db.Column(db.Integer, nullable=False, index=True)
aliasname = db.Column(db.String(64), nullable=False)
__table_args__ = (
db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'),
)

106
plugins/geenforce/parity.py Normal file
View File

@@ -0,0 +1,106 @@
"""P1 behavioral-parity harness (Gate A).
Proves the model is lossless WITHOUT byte-diffing: for each manifest, import it
into in-memory rows and render it back out, then check that
1. every entry is field-for-field identical (engine-relevant fields only,
dropping _comment and key order), in the same order, AND
2. the same entries fire in the same order for a set of machine-profile
fixtures, using the same filter logic the engine uses (filters.py).
Runs with no database (build_scope produces detached ORM objects whose
relationship lists are read directly), so it is cheap to re-run and wrap in CI.
Output is one readable line per scope.
"""
import json
import os
from .importer import build_scope
from .serializer import scope_to_manifest, canonical_entry
from .filters import applicable_entry_names
_FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'parityfixtures.json')
def load_fixtures():
with open(_FIXTURES_PATH) as handle:
return json.load(handle)
def check_scope(scopename, phase, original, fixtures):
"""Compare an original manifest against its import+export round-trip."""
rebuilt = scope_to_manifest(build_scope(scopename, phase, original))
orig_apps = original.get('Applications') or []
rebuilt_apps = rebuilt.get('Applications') or []
# Check 0: scope-level fields (Version, Site) round-trip.
firstdiff = None
for key in ('Version', 'Site'):
if str(original.get(key) or '') != str(rebuilt.get(key) or ''):
firstdiff = (f'scope field {key}: '
f'{original.get(key)!r} vs {rebuilt.get(key)!r}')
break
scope_ok = firstdiff is None
# Check 1: field-identical, order-preserving.
identical = 0
if firstdiff is None and len(orig_apps) != len(rebuilt_apps):
firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}')
for i in range(min(len(orig_apps), len(rebuilt_apps))):
co = canonical_entry(orig_apps[i])
cr = canonical_entry(rebuilt_apps[i])
if co == cr:
identical += 1
elif firstdiff is None:
name = orig_apps[i].get('Name', f'#{i}')
diffkeys = sorted(
k for k in set(co) | set(cr) if co.get(k) != cr.get(k))
firstdiff = f'entry "{name}" differs on {diffkeys}'
# Check 2: same entries fire in the same order for each profile. Inject the
# scope phase so PCTypesStrict is honored only for preinstall (see filters).
profiles_same = 0
for base_profile in fixtures:
profile = {**base_profile, 'phase': phase}
if (applicable_entry_names(orig_apps, profile)
== applicable_entry_names(rebuilt_apps, profile)):
profiles_same += 1
elif firstdiff is None:
firstdiff = f'filter mismatch for profile {profile.get("label")}'
passed = (scope_ok
and identical == len(orig_apps) == len(rebuilt_apps)
and profiles_same == len(fixtures))
return {
'scopename': scopename,
'phase': phase,
'entries_total': len(orig_apps),
'entries_identical': identical,
'profiles_total': len(fixtures),
'profiles_same': profiles_same,
'passed': passed,
'firstdiff': firstdiff,
}
def run_parity(manifests, fixtures=None):
"""Run parity over a list of (scopename, phase, manifest_dict). Returns
(results, all_passed)."""
fixtures = fixtures if fixtures is not None else load_fixtures()
results = [check_scope(name, phase, manifest, fixtures)
for name, phase, manifest in manifests]
return results, all(r['passed'] for r in results)
def format_result(result):
"""One human-readable line for a scope result."""
status = 'PASS' if result['passed'] else 'FAIL'
line = (f"scope {result['scopename']:<28} "
f"entries {result['entries_identical']}/{result['entries_total']} identical "
f"profiles {result['profiles_same']}/{result['profiles_total']} same-fire "
f"{status}")
if not result['passed'] and result['firstdiff']:
line += f"\n -> {result['firstdiff']}"
return line

View File

@@ -0,0 +1,20 @@
[
{"label": "collections-okuma-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0615", "machinenumber": "3201", "cmmversion": ""},
{"label": "collections-mtconnect-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC7501", "machinenumber": "7501", "cmmversion": ""},
{"label": "collections-other-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0001", "machinenumber": "0001", "cmmversion": ""},
{"label": "nocollections", "pctype": "gea-shopfloor-nocollections", "subtype": "", "hostname": "WJPC2000", "machinenumber": "", "cmmversion": ""},
{"label": "common-timeclock", "pctype": "gea-shopfloor-common", "subtype": "", "hostname": "WJPC3000", "machinenumber": "", "cmmversion": ""},
{"label": "cmm-2016", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM01", "machinenumber": "9101", "cmmversion": "2016"},
{"label": "cmm-2019", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM02", "machinenumber": "9102", "cmmversion": "2019"},
{"label": "cmm-2026", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM11", "machinenumber": "9111", "cmmversion": "2026"},
{"label": "cmm-noversion-legacy", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM99", "machinenumber": "9199", "cmmversion": ""},
{"label": "keyence-vr6000", "pctype": "Keyence", "subtype": "vr6000", "hostname": "WJKEY01", "machinenumber": "8001", "cmmversion": ""},
{"label": "keyence-vr3000", "pctype": "Keyence", "subtype": "vr3000", "hostname": "WJKEY02", "machinenumber": "8002", "cmmversion": ""},
{"label": "genspect", "pctype": "gea-shopfloor-genspect", "subtype": "", "hostname": "WJGEN01", "machinenumber": "8100", "cmmversion": ""},
{"label": "heattreat", "pctype": "gea-shopfloor-heattreat", "subtype": "", "hostname": "WJHT01", "machinenumber": "8200", "cmmversion": ""},
{"label": "waxtrace", "pctype": "gea-shopfloor-waxtrace", "subtype": "", "hostname": "WJWAX01", "machinenumber": "8300", "cmmversion": ""},
{"label": "partmarker", "pctype": "gea-shopfloor-partmarker", "subtype": "", "hostname": "WJPM01", "machinenumber": "8400", "cmmversion": ""},
{"label": "display", "pctype": "gea-shopfloor-display", "subtype": "", "hostname": "WJDISP01", "machinenumber": "", "cmmversion": ""},
{"label": "legacy-standard-machine", "pctype": "Standard", "subtype": "Machine", "hostname": "WJLEG01", "machinenumber": "3202", "cmmversion": ""},
{"label": "legacy-cmm-name", "pctype": "CMM", "subtype": "", "hostname": "WJLEG02", "machinenumber": "9102", "cmmversion": "2019"}
]

194
plugins/geenforce/plugin.py Normal file
View File

@@ -0,0 +1,194 @@
"""GE-Enforce manifest-store plugin.
Owns the imaging-PC-type scopes and their install manifests as shopdb data
(see the ge-enforce-plugin proposal in the project wiki). 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_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 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}.")
return [geenforce_cli]

View File

@@ -0,0 +1,133 @@
"""Rebuild manifest JSON from DB rows, and canonicalize entries for parity.
`scope_to_manifest` renders a ManifestScope back into the exact JSON structure
GE-Enforce consumes (Version / _comment / Applications, plus Site for
preinstall). `canonical_entry` reduces an Applications[] entry (from either the
on-share file or a rebuilt scope) to only the fields the engine reads, dropping
`_comment` and key order, so the parity harness can compare behavior not bytes.
"""
import json
# Scalar entry fields: model attribute -> manifest key. Emitted only when set,
# so a rebuilt entry has the same sparse key presence as the original.
_SCALAR_FIELDS = [
('name', 'Name'),
('entrytype', 'Type'),
('installer', 'Installer'),
('installargs', 'InstallArgs'),
('scriptpath', 'Script'),
('scriptargs', 'Args'),
('sourcepath', 'Source'),
('destination', 'Destination'),
('regpath', 'RegPath'),
('regname', 'RegName'),
('regtype', 'RegType'),
('detectionmethod', 'DetectionMethod'),
('detectionpath', 'DetectionPath'),
('detectionname', 'DetectionName'),
('detectionvalue', 'DetectionValue'),
('detectionpattern', 'DetectionPattern'),
('cmmversion', '_CmmVersion'),
('logfile', 'LogFile'),
('waittimeoutsec', 'WaitTimeoutSec'),
('applymode', 'ApplyMode'),
('updatewindow', 'UpdateWindow'),
]
# Boolean flags emitted only when True (matches how preinstall.json carries them).
_BOOL_FLAGS = [
('preenrollment', 'PreEnrollment'),
('killafterdetection', 'KillAfterDetection'),
('pctypesstrict', 'PCTypesStrict'),
]
# The complete set of engine-relevant keys the parity check compares.
ENGINE_KEYS = (
[mk for _, mk in _SCALAR_FIELDS]
+ [mk for _, mk in _BOOL_FLAGS]
+ ['PCTypes', 'TargetHostnames', 'TargetMachineNumbers', 'RegValue', 'InUseCheck']
)
def entry_to_dict(entry):
"""Render a ManifestEntry model back into a manifest Applications[] entry."""
result = {}
if entry.comment:
result['_comment'] = entry.comment
for attr, key in _SCALAR_FIELDS:
value = getattr(entry, attr)
if value is not None and value != '':
result[key] = value
# RegValue: stored as a raw JSON literal, reconstitute its real type.
if entry.regvalue is not None:
result['RegValue'] = json.loads(entry.regvalue)
# Multi-value filters (only when present).
if entry.pctypes:
result['PCTypes'] = [p.pctypevalue for p in entry.pctypes]
if entry.hostnames:
result['TargetHostnames'] = [h.hostnamepattern for h in entry.hostnames]
if entry.machinenumbers:
result['TargetMachineNumbers'] = [m.machinenumber
for m in entry.machinenumbers]
for attr, key in _BOOL_FLAGS:
if getattr(entry, attr):
result[key] = True
# InUseCheck (nested object + Processes[]).
if entry.inusecheck:
procs = []
for proc in entry.inusecheck.processes:
pd = {'Name': proc.processname}
if proc.exepath is not None:
pd['ExePath'] = proc.exepath
if proc.gracefulclosetimeoutsec is not None:
pd['GracefulCloseTimeoutSec'] = proc.gracefulclosetimeoutsec
procs.append(pd)
result['InUseCheck'] = {
'Behavior': entry.inusecheck.behavior,
'Processes': procs,
}
return result
def scope_to_manifest(scope):
"""Render a ManifestScope back into a full manifest.json structure."""
manifest = {'Version': scope.manifestversion}
if scope.topcomment:
manifest['_comment'] = scope.topcomment
if scope.site:
manifest['Site'] = scope.site
manifest['Applications'] = [entry_to_dict(e) for e in scope.entries]
return manifest
def scope_to_json(scope, indent=2):
"""The serialized JSON text a client/export would receive."""
return json.dumps(scope_to_manifest(scope), indent=indent)
def canonical_entry(entry_dict):
"""Reduce an Applications[] entry to engine-relevant fields for parity.
Drops `_comment` and key order. Normalizes InUseCheck and the process list
so two entries that behave identically compare equal regardless of source.
"""
canon = {}
for key in ENGINE_KEYS:
if key == 'InUseCheck':
continue
if key in entry_dict and entry_dict[key] is not None and entry_dict[key] != '':
canon[key] = entry_dict[key]
inuse = entry_dict.get('InUseCheck')
if inuse:
procs = []
for proc in inuse.get('Processes', []):
pd = {'Name': proc.get('Name')}
if proc.get('ExePath') is not None:
pd['ExePath'] = proc.get('ExePath')
if proc.get('GracefulCloseTimeoutSec') is not None:
pd['GracefulCloseTimeoutSec'] = proc.get('GracefulCloseTimeoutSec')
procs.append(pd)
canon['InUseCheck'] = {'Behavior': inuse.get('Behavior'),
'Processes': procs}
return canon

View File

@@ -0,0 +1,343 @@
"""DB-touching operations for the manifest store.
Kept out of the CLI and routes so both share one implementation:
- `replace_scope_draft` imports/re-imports a scope's DRAFT entries WITHOUT
touching its published-version history (re-import is idempotent + safe).
- `publish_scope` freezes the current draft into a new immutable snapshot.
- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle.
"""
import hashlib
import os
import tempfile
from datetime import datetime, timezone
from sqlalchemy import func
from shopdb.api import db, Application
from .models import (
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
ManifestEnforcementResult, ManifestPayload,
)
from .importer import build_entry
from .serializer import scope_to_json
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=None)
def _installed_app_model():
"""Lazily import the computers plugin's ComputerInstalledApp.
The computers plugin is optional; importing it lazily (not at module load)
keeps geenforce usable when computers is absent or disabled. Mirrors
collector._computer_models. Returns the model class, or None if absent.
"""
try:
from plugins.computers.models import ComputerInstalledApp
return ComputerInstalledApp
except ImportError:
return None
def replace_scope_draft(scopename, phase, manifest):
"""Create/refresh a scope and REPLACE its draft entries. Published versions
are left untouched. Returns the scope (uncommitted)."""
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
scope = ManifestScope(scopename=scopename, phase=phase)
db.session.add(scope)
scope.manifestversion = str(manifest.get('Version', '1.0'))
scope.topcomment = manifest.get('_comment')
scope.site = manifest.get('Site')
scope.iscommon = (scopename == 'common')
for entry in list(scope.entries):
db.session.delete(entry)
db.session.flush()
for i, entry_dict in enumerate(manifest.get('Applications') or []):
scope.entries.append(build_entry(entry_dict, i))
return scope
def publish_scope(scopename, phase, notes=None, publishedby=None):
"""Freeze the current draft into a new published snapshot. Returns the
version number (uncommitted)."""
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
raise ValueError(f'No scope {scopename}/{phase}')
text = scope_to_json(scope)
maxversion = db.session.query(
func.max(ManifestPublishedVersion.versionnumber)
).filter_by(scopeid=scope.scopeid).scalar() or 0
ManifestPublishedVersion.query.filter_by(
scopeid=scope.scopeid, iscurrent=True
).update({'iscurrent': False})
db.session.add(ManifestPublishedVersion(
scopeid=scope.scopeid,
versionnumber=maxversion + 1,
manifestjson=text,
publishedat=_utcnow(),
publishedby=publishedby,
iscurrent=True,
notes=notes))
return maxversion + 1
def rollback_scope(scopename, phase, versionnumber):
"""Make an older published version current again (uncommitted)."""
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
raise ValueError(f'No scope {scopename}/{phase}')
target = ManifestPublishedVersion.query.filter_by(
scopeid=scope.scopeid, versionnumber=versionnumber).first()
if not target:
raise ValueError(f'No version {versionnumber} for {scopename}')
ManifestPublishedVersion.query.filter_by(
scopeid=scope.scopeid, iscurrent=True
).update({'iscurrent': False})
target.iscurrent = True
return versionnumber
def record_enforcement_report(payload):
"""Record one PC's enforcement cycle (observed state). Upserts the latest
report per (hostname, scopename, phase) and keeps prior ones as history.
Payload (all but hostname optional):
hostname, scopename, phase, appliedversion, enforcerversion, lastcheckin,
counts {installed, skipped, failed, filtered},
results [{name, action, selfhealed, exitcode, message}]
Returns the new ManifestEnforcementReport (uncommitted).
"""
hostname = (payload.get('hostname') or '').strip()
if not hostname:
raise ValueError('hostname is required')
scopename = (payload.get('scopename') or '').strip()
phase = (payload.get('phase') or 'runtime').strip()
counts = payload.get('counts') or {}
results = payload.get('results') or []
# Guard wrong JSON shapes: a bad type must be a clean 400, not a 500 from a
# later .get()/iteration (the route maps ValueError to 400).
if not isinstance(counts, dict):
raise ValueError('counts must be an object')
if not isinstance(results, list):
raise ValueError('results must be a list')
failed = int(counts.get('failed', 0))
installed = int(counts.get('installed', 0))
# Derive status from EXPLICIT self-heal flags only, never the raw installed
# count: every healthy cycle runs Always/no-detection scripts (asset report,
# VNC firewall, EventSaver) that the engine counts as "installed", so keying
# self-heal off installed>0 would mark the common scope selfhealed forever
# and make 'ok' unreachable. A drift correction is one the client flags.
if failed > 0:
status = 'failed'
elif any(r.get('selfhealed') for r in results):
status = 'selfhealed'
else:
status = 'ok'
# Demote the prior current report for this host+scope. Hostname match is
# case-insensitive to match the ilike read path - otherwise a PC reporting
# its name in different casing would leave two iscurrent rows.
ManifestEnforcementReport.query.filter(
func.lower(ManifestEnforcementReport.hostname) == hostname.lower(),
ManifestEnforcementReport.scopename == scopename,
ManifestEnforcementReport.phase == phase,
ManifestEnforcementReport.iscurrent == True
).update({'iscurrent': False}, synchronize_session=False)
report = ManifestEnforcementReport(
hostname=hostname,
scopename=scopename,
phase=phase,
appliedversion=payload.get('appliedversion'),
enforcerversion=payload.get('enforcerversion'),
installedcount=installed,
skippedcount=int(counts.get('skipped', 0)),
failedcount=failed,
filteredcount=int(counts.get('filtered', 0)),
status=status,
lastcheckin=_parse_dt(payload.get('lastcheckin')),
receivedat=_utcnow(),
iscurrent=True)
for item in results:
report.results.append(ManifestEnforcementResult(
entryname=item.get('name', ''),
action=item.get('action', ''),
# Explicit flag only; do NOT infer from action == 'installed'
# (Always/no-detection scripts install every cycle without being a
# drift correction).
selfhealed=bool(item.get('selfhealed', False)),
exitcode=item.get('exitcode'),
message=item.get('message')))
db.session.add(report)
return report
def _parse_dt(value):
if not value:
return None
try:
return datetime.fromisoformat(str(value).replace('Z', '+00:00')).replace(
tzinfo=None)
except (ValueError, TypeError):
return None
def compliance_for_scope(scope):
"""Fleet-install coverage for a scope's app-linked entries.
One row per ManifestEntry that carries a curated appid (unlinked entries
are skipped), ordered by sortorder. Counts come from the computers plugin's
ComputerInstalledApp (active rows only, one row per PC per app). Degrades
gracefully with null counts when the computers plugin is absent.
Returns the response data dict (scopeid/scopename/phase/computersplugin/rows).
"""
linked = [e for e in sorted(scope.entries, key=lambda e: e.sortorder)
if e.appid is not None]
installedmodel = _installed_app_model()
computerspresent = installedmodel is not None
# One grouped query for the whole scope's appid set (fleet is tiny).
installedbyapp = {}
if computerspresent and linked:
appids = {e.appid for e in linked}
counted = db.session.query(
installedmodel.appid, func.count(installedmodel.id)
).filter(
installedmodel.isactive == True,
installedmodel.appid.in_(appids),
).group_by(installedmodel.appid).all()
installedbyapp = {appid: count for appid, count in counted}
rows = []
for entry in linked:
app = db.session.get(Application, entry.appid)
appname = app.appname if app else None
expectedversion = (entry.detectionvalue
if entry.detectionmethod == 'FileVersion' else None)
if not computerspresent:
installedcount = None
versionmatchcount = None
coveragenote = ('computers plugin not installed; install counts '
'unavailable')
else:
installedcount = installedbyapp.get(entry.appid, 0)
if installedcount == 0:
versionmatchcount = None if expectedversion is None else 0
coveragenote = 'not installed on any collected PC'
elif expectedversion is None:
versionmatchcount = None
method = entry.detectionmethod or 'none'
coveragenote = (f'installed on {installedcount} PC(s); no '
f'version target (detection is {method})')
else:
versionmatchcount = db.session.query(
func.count(installedmodel.id)
).filter(
installedmodel.isactive == True,
installedmodel.appid == entry.appid,
installedmodel.installedversion == expectedversion,
).scalar() or 0
coveragenote = (f'{versionmatchcount} of {installedcount} '
'collected PCs on the expected version')
rows.append({
'entryid': entry.entryid,
'entryname': entry.name,
'appid': entry.appid,
'appname': appname,
'expectedversion': expectedversion,
'installedcount': installedcount,
'versionmatchcount': versionmatchcount,
'coveragenote': coveragenote,
})
return {
'scopeid': scope.scopeid,
'scopename': scope.scopename,
'phase': scope.phase,
'computersplugin': computerspresent,
'rows': rows,
}
def store_inline_payload(entry, filename, contenttype, rawbytes):
"""Replace the entry's inline payload with these bytes (uncommitted).
Computes payloadsha256, upserts the single ManifestPayload row (the table
has no unique constraint on entryid, so the one-inline-payload-per-entry
rule is enforced here), and points the entry at it (payloadsource='inline').
Returns the ManifestPayload.
"""
sha = hashlib.sha256(rawbytes).hexdigest()
ManifestPayload.query.filter_by(entryid=entry.entryid).delete()
db.session.flush()
payload = ManifestPayload(
entryid=entry.entryid,
filename=filename,
contenttype=contenttype,
payloadbytes=rawbytes,
payloadsha256=sha,
uploadedat=_utcnow())
db.session.add(payload)
entry.payloadsource = 'inline'
entry.payloadref = filename
entry.payloadsha256 = sha
return payload
def export_scope_to_share(scopename, phase, shareroot):
"""Write a scope's current published JSON to the share, backing up the old
file to _meta/history first. Returns the written path."""
scope = ManifestScope.query.filter_by(
scopename=scopename, phase=phase).first()
if not scope:
raise ValueError(f'No scope {scopename}/{phase}')
published = scope.publishedversions.filter_by(iscurrent=True).first()
if not published:
raise ValueError(f'{scopename} has no published version')
if phase == 'preinstall':
target = os.path.join(shareroot, 'preinstall.json')
else:
target = os.path.join(shareroot, scopename, 'manifest.json')
os.makedirs(os.path.dirname(target), exist_ok=True)
if os.path.isfile(target):
historydir = os.path.join(shareroot, '_meta', 'history')
os.makedirs(historydir, exist_ok=True)
stamp = _utcnow().strftime('%Y%m%d-%H%M%S')
with open(target) as src:
old = src.read()
with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst:
dst.write(old)
# Atomic write: a partial/failed write must never leave the live on-share
# manifest (which every PC reads) truncated. Write a temp file in the same
# directory, then rename over the target.
targetdir = os.path.dirname(target)
fd, tmppath = tempfile.mkstemp(dir=targetdir, suffix='.tmp')
try:
with os.fdopen(fd, 'w') as handle:
handle.write(published.manifestjson)
os.replace(tmppath, target)
except Exception:
if os.path.exists(tmppath):
os.remove(tmppath)
raise
return target