Build GE-Enforce manifest-store plugin (P0/P1): model, importer, parity gate
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:
133
plugins/geenforce/serializer.py
Normal file
133
plugins/geenforce/serializer.py
Normal 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
|
||||
Reference in New Issue
Block a user