"""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"" 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"" 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) 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'), )