Files
shopdb-flask/plugins/geenforce/serializer.py
cproudlock d85b33bd68
All checks were successful
CI / backend (push) Successful in 1m36s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 8s
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>
2026-07-12 16:53:18 -04:00

134 lines
4.9 KiB
Python

"""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