A display knows whether it is a Dashboard, a Lobby screen or the 3D print room - the dispatcher reads C:\Enrollment\display-type.txt to choose which page to open. It never told shopdb, so the fleet table inferred it from the DashboardDefault fqdn mapping, which is empty unless somebody added a row per kiosk. The column was blank for every display. The client now reads that file and reports it, the report stores it, and the API prefers the reported value with the old mapping left as a fallback for hosts still on an older client. Reported by the device beats inferred from a lookup table, the same way enforcerversion already works. A PC with no display-type.txt reports nothing rather than something invented, and an empty string lands as NULL. Two guards had to learn about it. The DDL parity check read only the 0001 baseline, so a column added by a later revision looked like drift even though its migration existed; it now runs the whole chain, which is what 'do the models match what the migrations build' means. 0002 added a whole table rather than a column, which is why this is the first time it bit.
377 lines
17 KiB
Python
377 lines
17 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')
|
|
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)
|