Files
shopdb-flask/plugins/geenforce/models/manifest.py
cproudlock 838932a72d geenforce: gate publishing on the library version, not on the manifest's own
The publish gate exists because a minor version bump that NARROWS behaviour is
not backward compatible: _CmmVersion arrived in lib 2.6, and an older lib does
not know the field, so every gated entry looks unfiltered and it installs every
PC-DMIS version it cannot detect, on every CMM, in one cycle.

It was comparing the fleet's reported library versions against manifestversion.
That is the manifest's own 'Version' field. For a share-imported manifest the
two numbering schemes happen to coincide; for a scope authored in code they do
not, and seed_display_scope writes '2.0' - which every kiosk exceeds. So the
gate passed on the scope that most needed it.

A scope now declares minlibversion. Unset, the requirement is DERIVED from what
the manifest actually uses, so a scope written before this column existed is
still judged on its contents rather than on a number that says nothing about the
library. Only features that narrow behaviour belong in that table; an addition
an old lib ignores harmlessly needs no floor. manifestversion remains the last
fallback, which preserves what share-imported manifests already relied on.
2026-08-14 13:47:11 -04:00

386 lines
18 KiB
Python

"""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')
# Lowest GE-Enforce client library that may enforce this scope, e.g. '2.6'.
#
# SEPARATE FROM manifestversion, which is the manifest's own 'Version' field
# and is whatever its author wrote - seed_display_scope picks '2.0'. The
# publish gate used manifestversion as the library floor, so for every
# code-authored scope it compared the fleet against a number that says
# nothing about library features, and passed. NULL means "derive it", see
# service.required_libversion.
minlibversion = db.Column(db.String(16), nullable=True)
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
# Optional CURATED link to a core Applications catalog row (soft ref, no FK
# to keep the plugin decoupled). shopdb metadata only - NOT part of the
# manifest JSON the engine reads, so it never affects enforcement or parity.
appid = db.Column(db.Integer, nullable=True)
# 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 ManifestEnforcementReport(db.Model):
"""One enforcement cycle reported by a PC (observed state).
Each cycle the client POSTs its result for a scope: the published version it
actually applied (so shopdb knows whether the PC RECEIVED the latest update),
the enforcer version, and the installed/skipped/failed/filtered counts. The
latest report per (hostname, scopename, phase) carries `iscurrent`; older
ones are history. Pairs desired state (the manifest) with observed state.
"""
__tablename__ = 'manifestenforcementreports'
reportid = db.Column(db.Integer, primary_key=True)
hostname = db.Column(db.String(100), nullable=False, index=True)
scopename = db.Column(db.String(64), nullable=False)
phase = db.Column(db.String(16), nullable=False, default='runtime')
# Published version the client actually ran; compare to the scope's current
# published version to see whether this PC received the latest manifest.
appliedversion = db.Column(db.Integer, nullable=True)
enforcerversion = db.Column(db.String(20), nullable=True)
# What the PC says it IS within its scope (a display's Dashboard / Lobby /
# 3DPrintRoom). Reported by the device, which reads it from its own
# enrollment file - not inferred from a mapping table that may have no row
# for this host.
subtype = db.Column(db.String(50), nullable=True)
installedcount = db.Column(db.Integer, nullable=False, default=0)
skippedcount = db.Column(db.Integer, nullable=False, default=0)
failedcount = db.Column(db.Integer, nullable=False, default=0)
filteredcount = db.Column(db.Integer, nullable=False, default=0)
# 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure).
status = db.Column(db.String(16), nullable=False, default='ok')
lastcheckin = db.Column(db.DateTime, nullable=True) # PC-reported time
receivedat = db.Column(db.DateTime, nullable=False) # server time
iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True)
results = db.relationship(
'ManifestEnforcementResult', back_populates='report',
cascade='all, delete-orphan', lazy='selectin')
__table_args__ = (
db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'),
)
class ManifestEnforcementResult(db.Model):
"""One entry's outcome within an enforcement cycle (self-heal detail)."""
__tablename__ = 'manifestenforcementresults'
resultid = db.Column(db.Integer, primary_key=True)
reportid = db.Column(
db.Integer,
db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'),
nullable=False, index=True)
entryname = db.Column(db.String(128), nullable=False)
# 'installed' (action fired - a self-heal when it should already be present),
# 'skipped' (detected present), 'failed', 'filtered'.
action = db.Column(db.String(16), nullable=False)
# True when this install was a drift correction (self-heal), not a first
# install. Client-supplied; defaults to whether the action installed.
selfhealed = db.Column(db.Boolean, nullable=False, default=False)
exitcode = db.Column(db.Integer, nullable=True)
message = db.Column(db.Text, nullable=True) # warning / error text
report = db.relationship('ManifestEnforcementReport', back_populates='results')
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'),
)
class ManifestBlob(db.Model):
"""Content-addressed payload blob for http-delivered installers.
Bytes live on disk at <instance>/geenforce/payloads/<sha256> (deduped by
content, so a payload shared by many entries is stored once). Manifest
entries reference a blob by payloadsha256; the client fetches it from
GET /api/geenforce/payload/<sha256> over HTTPS and verifies the hash. This
is how big installers reach share-less (Intune/local-account) PCs.
"""
__tablename__ = 'manifestblobs'
sha256 = db.Column(db.String(64), primary_key=True)
filename = db.Column(db.String(255), nullable=False)
contenttype = db.Column(db.String(128), nullable=True)
sizebytes = db.Column(db.BigInteger, nullable=False)
createdat = db.Column(db.DateTime, nullable=False)