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:
288
plugins/geenforce/models/manifest.py
Normal file
288
plugins/geenforce/models/manifest.py
Normal 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'),
|
||||
)
|
||||
Reference in New Issue
Block a user