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.
386 lines
18 KiB
Python
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)
|