Build GE-Enforce manifest-store plugin (P0/P1): model, importer, parity gate
All checks were successful
CI / backend (push) Successful in 1m36s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s

First execution phases of docs/proposals/ge-enforce-plugin.md. The GE-Enforce
manifest becomes shopdb data.

P0 scaffold: new geenforce plugin (api_prefix /api/geenforce, default_enabled
false, core_version >=0.7.0). Registered in PLUGIN_TABLE_OWNERS (ADR-008); its
0001 baseline really creates the tables.

P1a model: one wide manifestentries table + entrytype discriminator (not STI,
not JSON blob), manifestscopes (UNIQUE scopename+phase), the three multi-value
filter child tables, inusechecks + processes, immutable manifestpublishedversions
(frozen rendered JSON), manifestpayloads (inline, capped), pctypealiases
(mirror of the engine lib's alias graph). regvalue stored as its raw JSON
literal so DWord typing survives.

P1c importer + exporter: parse common + gea-shopfloor-* + preinstall.json into
draft rows and rebuild the JSON verbatim from rows in sortorder.

P1d parity harness (GATE A): filters.py mirrors the engine's four filter
functions + alias graph; parity.py proves import+export is behaviorally lossless
(field-identical + same-entries-fire across 18 machine-profile fixtures) WITHOUT
byte-diffing. Verified PASS against all 11 real reference manifests (64 entries)
and a synthetic site-neutral fixture covering every type/filter (the CI gate).

First slice (gea-shopfloor-cmm shape): service layer (import/publish/rollback/
export-to-share), CLI (parity, import-share, publish, export-share), and the
client endpoint GET /api/geenforce/manifest serving the current published
snapshot (never the draft) with ETag/304. Split permissions
geenforce.manage/publish/fetch. Tests prove import->publish->serve, draft edits
never change served bytes, publish+rollback, and auth (401 unauth/wrong-scope).

Contract 0.11.0: added service_token_authorized(scope) to shopdb.api so plugin
service endpoints authorize a scoped managed token without importing core token
internals. Documented in PLUGIN-HOOKS.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
cproudlock
2026-07-12 16:53:18 -04:00
parent cf2f9c308e
commit d85b33bd68
24 changed files with 1830 additions and 3 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,117 @@
"""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), plus an optional
GEENFORCE_API_KEY env bootstrap.
"""
from functools import wraps
from flask import Blueprint, request, current_app, Response
from flask_jwt_extended import jwt_required
from shopdb.api import (
db, success_response, error_response, ErrorCodes, require_permission,
service_token_authorized,
)
from ..models import ManifestScope, ManifestPublishedVersion
from ..serializer import scope_to_manifest
geenforce_bp = Blueprint('geenforce', __name__)
FETCH_SCOPE = 'geenforce.fetch'
def require_fetch_token(f):
"""Require a geenforce.fetch service token OR the env bootstrap key."""
@wraps(f)
def decorated(*args, **kwargs):
if service_token_authorized(FETCH_SCOPE):
return f(*args, **kwargs)
expected = current_app.config.get('GEENFORCE_API_KEY')
if expected and request.headers.get('X-API-Key') == expected:
return f(*args, **kwargs)
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
http_code=401)
return decorated
# -- 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)})
# -- 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)})

View File

@@ -0,0 +1,126 @@
"""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):
"""Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection."""
values = entry.get('PCTypes') or []
if not values:
return True
if not pctype:
return True
# Names the current PC matches: bare type, "type-subtype", and all aliases.
mynames = set()
mynames.add(pctype.lower())
seeds = [pctype]
if subtype:
combined = f'{pctype}-{subtype}'
mynames.add(combined.lower())
seeds.append(combined)
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
# 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.
"""
return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype'))
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,102 @@
"""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}
def build_entry(entry_dict, sortorder):
"""Build a ManifestEntry (+ children) from one Applications[] entry."""
entry = ManifestEntry(sortorder=sortorder,
name=entry_dict.get('Name'),
entrytype=entry_dict.get('Type'))
if '_comment' in entry_dict:
entry.comment = entry_dict['_comment']
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.
if 'RegValue' in entry_dict:
entry.regvalue = json.dumps(entry_dict['RegValue'])
for key, attr in _FLAG_TO_ATTR.items():
if entry_dict.get(key):
setattr(entry, attr, True)
# Multi-value filters -> child rows (preserve order).
for i, value in enumerate(entry_dict.get('PCTypes') or []):
entry.pctypes.append(ManifestEntryPcType(sortorder=i, pctypevalue=value))
for i, value in enumerate(entry_dict.get('TargetHostnames') or []):
entry.hostnames.append(
ManifestEntryHostname(sortorder=i, hostnamepattern=value))
for i, value in enumerate(entry_dict.get('TargetMachineNumbers') or []):
entry.machinenumbers.append(
ManifestEntryMachineNumber(sortorder=i, machinenumber=str(value)))
# 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
return entry
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,13 @@
{
"name": "geenforce",
"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,198 @@
"""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('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(
'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('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,41 @@
"""GE-Enforce plugin models."""
from .manifest import (
ManifestScope,
ManifestEntry,
ManifestEntryPcType,
ManifestEntryHostname,
ManifestEntryMachineNumber,
ManifestInUseCheck,
ManifestInUseCheckProcess,
ManifestPublishedVersion,
ManifestPayload,
PcTypeAlias,
PHASES,
ENTRY_TYPES,
REG_TYPES,
PAYLOAD_SOURCES,
DETECTION_METHODS,
APPLY_MODES,
INUSE_BEHAVIORS,
)
__all__ = [
'ManifestScope',
'ManifestEntry',
'ManifestEntryPcType',
'ManifestEntryHostname',
'ManifestEntryMachineNumber',
'ManifestInUseCheck',
'ManifestInUseCheckProcess',
'ManifestPublishedVersion',
'ManifestPayload',
'PcTypeAlias',
'PHASES',
'ENTRY_TYPES',
'REG_TYPES',
'PAYLOAD_SOURCES',
'DETECTION_METHODS',
'APPLY_MODES',
'INUSE_BEHAVIORS',
]

View File

@@ -0,0 +1,288 @@
"""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 docs/proposals/ge-enforce-plugin.md):
- 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
# 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 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'),
)

View File

@@ -0,0 +1,94 @@
"""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 1: field-identical, order-preserving.
identical = 0
firstdiff = None
if 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.
profiles_same = 0
for profile in fixtures:
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 = (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"}
]

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

@@ -0,0 +1,178 @@
"""GE-Enforce manifest-store plugin.
Owns the imaging-PC-type scopes and their install manifests as shopdb data
(see docs/proposals/ge-enforce-plugin.md). This is the P0/P1 foundation: models,
the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and
client cutover come in later phases.
"""
import json
import logging
from pathlib import Path
from typing import List, Dict, Optional, Type
import click
from flask import Flask, Blueprint
from shopdb.plugins.base import BasePlugin, PluginMeta
from shopdb.api import db
from .api import geenforce_bp
from .models import (
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
ManifestPublishedVersion, ManifestPayload, 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, 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'),
]
def init_app(self, app: Flask, db_instance) -> None:
logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})")
def on_install(self, app: Flask) -> None:
with app.app_context():
self._seed_aliases()
db.session.commit()
logger.info("GE-Enforce plugin installed")
def _seed_aliases(self) -> None:
"""Seed pctypealiases from the engine lib's alias graph (idempotent)."""
for group_index, group in enumerate(ALIAS_GROUPS):
for aliasname in group:
exists = PcTypeAlias.query.filter_by(
aliasgroup=group_index, aliasname=aliasname).first()
if not exists:
db.session.add(PcTypeAlias(
aliasgroup=group_index, aliasname=aliasname))
def get_cli_commands(self) -> List:
"""CLI: parity (Gate A), import-share, export-share, publish."""
@click.group('geenforce')
def geenforce_cli():
"""GE-Enforce manifest-store commands."""
@geenforce_cli.command('parity')
@click.option('--shareroot', required=True,
help='GE-Enforce share root (contains common/, '
'gea-shopfloor-*/).')
@click.option('--preinstall', default=None,
help='Optional path to a preinstall.json to include.')
def parity_cmd(shareroot, preinstall):
"""Prove import+export is behaviorally lossless (Gate A). DB-free."""
from .importer import discover_share, load_manifest_file
from .parity import run_parity, format_result
manifests = list(discover_share(shareroot))
if preinstall:
manifests.append(
('preinstall', 'preinstall', load_manifest_file(preinstall)))
results, ok = run_parity(manifests)
for result in results:
click.echo(format_result(result))
click.echo(f"RESULT: {'PASS' if ok else 'FAIL'} "
f"({len(results)} scopes)")
raise SystemExit(0 if ok else 1)
@geenforce_cli.command('import-share')
@click.option('--shareroot', required=True)
@click.option('--preinstall', default=None)
@click.option('--scope', default=None,
help='Import only this scope name (else all).')
def import_share_cmd(shareroot, preinstall, scope):
"""Import on-share manifests into draft rows (idempotent rebuild)."""
from flask import current_app
from .importer import discover_share, load_manifest_file, build_scope
from .service import replace_scope_draft
with current_app.app_context():
sources = list(discover_share(shareroot))
if preinstall:
sources.append(('preinstall', 'preinstall',
load_manifest_file(preinstall)))
count = 0
for name, phase, manifest in sources:
if scope and name != scope:
continue
replace_scope_draft(name, phase, manifest)
count += 1
db.session.commit()
click.echo(f"Imported {count} scope(s).")
@geenforce_cli.command('publish')
@click.argument('scopename')
@click.option('--phase', default='runtime')
@click.option('--notes', default=None)
def publish_cmd(scopename, phase, notes):
"""Freeze the current draft of a scope into a published snapshot."""
from flask import current_app
from .service import publish_scope
with current_app.app_context():
version = publish_scope(scopename, phase, notes=notes)
db.session.commit()
click.echo(f"Published {scopename}/{phase} as v{version}.")
@geenforce_cli.command('export-share')
@click.argument('scopename')
@click.option('--shareroot', required=True)
@click.option('--phase', default='runtime')
def export_share_cmd(scopename, shareroot, phase):
"""Write a scope's published JSON to the share (with history backup)."""
from flask import current_app
from .service import export_scope_to_share
with current_app.app_context():
path = export_scope_to_share(scopename, phase, shareroot)
click.echo(f"Exported to {path}.")
return [geenforce_cli]

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,121 @@
"""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 os
from datetime import datetime, timezone
from sqlalchemy import func
from shopdb.api import db
from .models import ManifestScope, ManifestPublishedVersion
from .importer import build_entry
from .serializer import scope_to_json
def _utcnow():
return datetime.now(timezone.utc).replace(tzinfo=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 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)
with open(target, 'w') as handle:
handle.write(published.manifestjson)
return target