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:
121
plugins/geenforce/service.py
Normal file
121
plugins/geenforce/service.py
Normal 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
|
||||
Reference in New Issue
Block a user