Files
shopdb-flask/plugins/geenforce/importer.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

103 lines
4.2 KiB
Python

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