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:
5
plugins/geenforce/__init__.py
Normal file
5
plugins/geenforce/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""GE-Enforce manifest-store plugin package."""
|
||||
|
||||
from .plugin import GeEnforcePlugin
|
||||
|
||||
__all__ = ['GeEnforcePlugin']
|
||||
5
plugins/geenforce/api/__init__.py
Normal file
5
plugins/geenforce/api/__init__.py
Normal file
@@ -0,0 +1,5 @@
|
||||
"""GE-Enforce plugin API blueprint."""
|
||||
|
||||
from .routes import geenforce_bp
|
||||
|
||||
__all__ = ['geenforce_bp']
|
||||
117
plugins/geenforce/api/routes.py
Normal file
117
plugins/geenforce/api/routes.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""GE-Enforce plugin API.
|
||||
|
||||
Two audiences:
|
||||
- Admin (JWT + geenforce.manage): browse scopes and preview the draft manifest.
|
||||
Full CRUD + publish lands in P2; this is the P1/first-slice read surface.
|
||||
- Client (service token, geenforce.fetch scope): GET /manifest serves the
|
||||
CURRENT PUBLISHED snapshot for a scope, never the live draft. Auth mirrors the
|
||||
collector's managed-token pattern (X-API-Key or Bearer PAT), plus an optional
|
||||
GEENFORCE_API_KEY env bootstrap.
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, current_app, Response
|
||||
from flask_jwt_extended import jwt_required
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
service_token_authorized,
|
||||
)
|
||||
|
||||
from ..models import ManifestScope, ManifestPublishedVersion
|
||||
from ..serializer import scope_to_manifest
|
||||
|
||||
geenforce_bp = Blueprint('geenforce', __name__)
|
||||
|
||||
FETCH_SCOPE = 'geenforce.fetch'
|
||||
|
||||
|
||||
def require_fetch_token(f):
|
||||
"""Require a geenforce.fetch service token OR the env bootstrap key."""
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if service_token_authorized(FETCH_SCOPE):
|
||||
return f(*args, **kwargs)
|
||||
expected = current_app.config.get('GEENFORCE_API_KEY')
|
||||
if expected and request.headers.get('X-API-Key') == expected:
|
||||
return f(*args, **kwargs)
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
return decorated
|
||||
|
||||
|
||||
# -- client-facing endpoint ---------------------------------------------------
|
||||
|
||||
@geenforce_bp.route('/manifest', methods=['GET'])
|
||||
@require_fetch_token
|
||||
def get_manifest():
|
||||
"""Serve the current published manifest for a scope (fat-client: full scope).
|
||||
|
||||
Query: pctype (=scopename, required), phase (default runtime). The engine
|
||||
filters client-side, matching today, so subtype/hostname/machinenumber/
|
||||
cmmversion are accepted but not applied here.
|
||||
"""
|
||||
scopename = (request.args.get('pctype') or '').strip()
|
||||
phase = (request.args.get('phase') or 'runtime').strip()
|
||||
if not scopename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'pctype is required', http_code=400)
|
||||
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'No scope: {scopename}', http_code=404)
|
||||
|
||||
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
if not published:
|
||||
return error_response(ErrorCodes.NOT_FOUND,
|
||||
f'{scopename} has no published version',
|
||||
http_code=404)
|
||||
|
||||
etag = f'"{scope.scopeid}-v{published.versionnumber}"'
|
||||
if request.headers.get('If-None-Match') == etag:
|
||||
return Response(status=304, headers={'ETag': etag})
|
||||
return Response(published.manifestjson, mimetype='application/json',
|
||||
headers={'ETag': etag,
|
||||
'X-Manifest-Version': str(published.versionnumber)})
|
||||
|
||||
|
||||
# -- admin read surface (P2 adds full CRUD + publish) -------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def list_scopes():
|
||||
"""List imaging PC-type scopes with entry + published-version counts."""
|
||||
scopes = ManifestScope.query.order_by(
|
||||
ManifestScope.phase, ManifestScope.scopename).all()
|
||||
data = []
|
||||
for scope in scopes:
|
||||
current = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
data.append({
|
||||
'scopeid': scope.scopeid,
|
||||
'scopename': scope.scopename,
|
||||
'phase': scope.phase,
|
||||
'manifestversion': scope.manifestversion,
|
||||
'computertypeid': scope.computertypeid,
|
||||
'measuringtooltypeid': scope.measuringtooltypeid,
|
||||
'iscommon': scope.iscommon,
|
||||
'entrycount': len(scope.entries),
|
||||
'publishedversion': current.versionnumber if current else None,
|
||||
})
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/preview', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def preview_scope(scopeid):
|
||||
"""Render the DRAFT manifest JSON a publish would freeze (review before ship)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
||||
http_code=404)
|
||||
return success_response({'scopename': scope.scopename,
|
||||
'manifest': scope_to_manifest(scope)})
|
||||
126
plugins/geenforce/filters.py
Normal file
126
plugins/geenforce/filters.py
Normal file
@@ -0,0 +1,126 @@
|
||||
"""Python mirror of the GE-Enforce engine's targeting filters.
|
||||
|
||||
These re-implement, field for field, the four filter functions in
|
||||
`Install-FromManifest.ps1` (Test-PCTypeMatches, Test-HostnameMatches,
|
||||
Test-MachineNumberMatches, Test-CmmVersionMatches) plus the PCTypes alias graph
|
||||
(lines 463-475). They operate on plain manifest-entry dicts (the exact shape of
|
||||
an Applications[] entry) and a machine-profile dict, so the SAME code answers
|
||||
two questions:
|
||||
|
||||
1. Parity harness: do the on-share manifest and the shopdb-rebuilt manifest
|
||||
select the same entries in the same order for a set of machine profiles?
|
||||
2. Simulator: "what would PC Y get" - which entries apply and why.
|
||||
|
||||
The engine lib stays the single source of truth; this is a mirror. If the lib's
|
||||
alias map changes, ALIAS_GROUPS here must change with it (the parity harness's
|
||||
legacy-name profiles fail loudly on divergence).
|
||||
"""
|
||||
|
||||
import fnmatch
|
||||
|
||||
# Mirror of $script:_pcTypeAliasGroups in Install-FromManifest.ps1:463-475.
|
||||
# Rows are alias sets: names in the same set all match the same identity.
|
||||
ALIAS_GROUPS = [
|
||||
('Standard', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections',
|
||||
'gea-shopfloor-common'),
|
||||
('Standard-Machine', 'gea-shopfloor-collections', 'gea-shopfloor-nocollections'),
|
||||
('Standard-Timeclock', 'gea-shopfloor-common'),
|
||||
('CMM', 'gea-shopfloor-cmm'),
|
||||
('Keyence', 'gea-shopfloor-keyence'),
|
||||
('Lab', 'gea-shopfloor-common'),
|
||||
('WaxAndTrace', 'gea-shopfloor-waxtrace'),
|
||||
('Genspect', 'gea-shopfloor-genspect'),
|
||||
('Display', 'gea-shopfloor-display'),
|
||||
('Heattreat', 'gea-shopfloor-heattreat'),
|
||||
('PartMarker', 'gea-shopfloor-partmarker'),
|
||||
]
|
||||
|
||||
|
||||
def _alias_sets(name):
|
||||
"""Every alias group containing `name` (case-insensitive)."""
|
||||
lname = (name or '').lower()
|
||||
return [g for g in ALIAS_GROUPS if any(n.lower() == lname for n in g)]
|
||||
|
||||
|
||||
def matches_pctype(entry, pctype, subtype=None):
|
||||
"""Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection."""
|
||||
values = entry.get('PCTypes') or []
|
||||
if not values:
|
||||
return True
|
||||
if not pctype:
|
||||
return True
|
||||
# Names the current PC matches: bare type, "type-subtype", and all aliases.
|
||||
mynames = set()
|
||||
mynames.add(pctype.lower())
|
||||
seeds = [pctype]
|
||||
if subtype:
|
||||
combined = f'{pctype}-{subtype}'
|
||||
mynames.add(combined.lower())
|
||||
seeds.append(combined)
|
||||
for seed in seeds:
|
||||
for group in _alias_sets(seed):
|
||||
for alias in group:
|
||||
mynames.add(alias.lower())
|
||||
for value in values:
|
||||
if value == '*':
|
||||
return True
|
||||
if value.lower() in mynames:
|
||||
return True
|
||||
# The manifest value may itself be an alias - expand and check overlap.
|
||||
for group in _alias_sets(value):
|
||||
for alias in group:
|
||||
if alias.lower() in mynames:
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def matches_hostname(entry, hostname):
|
||||
"""Test-HostnameMatches: exact or PowerShell -like glob (*, ?, [set])."""
|
||||
patterns = entry.get('TargetHostnames') or []
|
||||
if not patterns:
|
||||
return True
|
||||
myname = (hostname or '').lower()
|
||||
for pattern in patterns:
|
||||
lpattern = (pattern or '').lower()
|
||||
if lpattern == myname:
|
||||
return True
|
||||
if fnmatch.fnmatch(myname, lpattern):
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def matches_machinenumber(entry, machinenumber):
|
||||
"""Test-MachineNumberMatches: filter present but PC has no number = exclude."""
|
||||
numbers = entry.get('TargetMachineNumbers') or []
|
||||
if not numbers:
|
||||
return True
|
||||
if not machinenumber:
|
||||
return False
|
||||
mynumber = str(machinenumber).lower()
|
||||
return any(str(n).lower() == mynumber for n in numbers)
|
||||
|
||||
|
||||
def matches_cmmversion(entry, cmmversion):
|
||||
"""Test-CmmVersionMatches: untagged always applies; no PC version = install-all."""
|
||||
tag = entry.get('_CmmVersion')
|
||||
if not tag:
|
||||
return True
|
||||
if not cmmversion:
|
||||
return True
|
||||
return str(tag).lower() == str(cmmversion).lower()
|
||||
|
||||
|
||||
def entry_applies(entry, profile):
|
||||
"""All four filters ANDed, exactly as the engine's main loop applies them.
|
||||
|
||||
`profile` keys: pctype, subtype, hostname, machinenumber, cmmversion.
|
||||
"""
|
||||
return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype'))
|
||||
and matches_hostname(entry, profile.get('hostname'))
|
||||
and matches_machinenumber(entry, profile.get('machinenumber'))
|
||||
and matches_cmmversion(entry, profile.get('cmmversion')))
|
||||
|
||||
|
||||
def applicable_entry_names(applications, profile):
|
||||
"""Ordered list of entry Names that pass all filters for a profile."""
|
||||
return [e.get('Name') for e in applications if entry_applies(e, profile)]
|
||||
102
plugins/geenforce/importer.py
Normal file
102
plugins/geenforce/importer.py
Normal file
@@ -0,0 +1,102 @@
|
||||
"""Parse on-share manifest.json / preinstall.json into draft DB rows.
|
||||
|
||||
The reverse of serializer.py. `build_scope` turns a parsed manifest dict into a
|
||||
ManifestScope with its ordered entries and child rows (not yet committed).
|
||||
`discover_share` walks a GE-Enforce share root and yields (scopename, phase,
|
||||
manifest_dict) for common + every gea-shopfloor-* runtime manifest, skipping
|
||||
`.bak` variants. `load_preinstall` reads a preinstall.json.
|
||||
|
||||
Import is a rebuild: the caller replaces a scope's draft rows so re-running is
|
||||
idempotent.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from .models import (
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
||||
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
)
|
||||
from .serializer import _SCALAR_FIELDS, _BOOL_FLAGS
|
||||
|
||||
# manifest key -> model attr (reverse of the serializer's scalar map).
|
||||
_KEY_TO_ATTR = {mk: attr for attr, mk in _SCALAR_FIELDS}
|
||||
_FLAG_TO_ATTR = {mk: attr for attr, mk in _BOOL_FLAGS}
|
||||
|
||||
|
||||
def build_entry(entry_dict, sortorder):
|
||||
"""Build a ManifestEntry (+ children) from one Applications[] entry."""
|
||||
entry = ManifestEntry(sortorder=sortorder,
|
||||
name=entry_dict.get('Name'),
|
||||
entrytype=entry_dict.get('Type'))
|
||||
if '_comment' in entry_dict:
|
||||
entry.comment = entry_dict['_comment']
|
||||
for key, attr in _KEY_TO_ATTR.items():
|
||||
if attr in ('name', 'entrytype'):
|
||||
continue
|
||||
if key in entry_dict and entry_dict[key] is not None:
|
||||
setattr(entry, attr, entry_dict[key])
|
||||
# RegValue stored as its raw JSON literal so DWord vs string typing survives.
|
||||
if 'RegValue' in entry_dict:
|
||||
entry.regvalue = json.dumps(entry_dict['RegValue'])
|
||||
for key, attr in _FLAG_TO_ATTR.items():
|
||||
if entry_dict.get(key):
|
||||
setattr(entry, attr, True)
|
||||
# Multi-value filters -> child rows (preserve order).
|
||||
for i, value in enumerate(entry_dict.get('PCTypes') or []):
|
||||
entry.pctypes.append(ManifestEntryPcType(sortorder=i, pctypevalue=value))
|
||||
for i, value in enumerate(entry_dict.get('TargetHostnames') or []):
|
||||
entry.hostnames.append(
|
||||
ManifestEntryHostname(sortorder=i, hostnamepattern=value))
|
||||
for i, value in enumerate(entry_dict.get('TargetMachineNumbers') or []):
|
||||
entry.machinenumbers.append(
|
||||
ManifestEntryMachineNumber(sortorder=i, machinenumber=str(value)))
|
||||
# InUseCheck object + Processes[].
|
||||
inuse = entry_dict.get('InUseCheck')
|
||||
if inuse:
|
||||
check = ManifestInUseCheck(behavior=inuse.get('Behavior'))
|
||||
for i, proc in enumerate(inuse.get('Processes') or []):
|
||||
check.processes.append(ManifestInUseCheckProcess(
|
||||
sortorder=i,
|
||||
processname=proc.get('Name'),
|
||||
exepath=proc.get('ExePath'),
|
||||
gracefulclosetimeoutsec=proc.get('GracefulCloseTimeoutSec')))
|
||||
entry.inusecheck = check
|
||||
return entry
|
||||
|
||||
|
||||
def build_scope(scopename, phase, manifest_dict, iscommon=False):
|
||||
"""Build a ManifestScope (+ entries) from a parsed manifest dict."""
|
||||
scope = ManifestScope(
|
||||
scopename=scopename,
|
||||
phase=phase,
|
||||
manifestversion=str(manifest_dict.get('Version', '1.0')),
|
||||
topcomment=manifest_dict.get('_comment'),
|
||||
site=manifest_dict.get('Site'),
|
||||
iscommon=iscommon)
|
||||
for i, entry_dict in enumerate(manifest_dict.get('Applications') or []):
|
||||
scope.entries.append(build_entry(entry_dict, i))
|
||||
return scope
|
||||
|
||||
|
||||
def discover_share(shareroot):
|
||||
"""Yield (scopename, phase, manifest_dict) for runtime manifests on a share.
|
||||
|
||||
Reads `common/manifest.json` and every `gea-shopfloor-*/manifest.json`.
|
||||
Skips `.bak` / `.pre-*.bak` variants (only the exact `manifest.json`).
|
||||
"""
|
||||
for name in sorted(os.listdir(shareroot)):
|
||||
if name != 'common' and not name.startswith('gea-shopfloor-'):
|
||||
continue
|
||||
path = os.path.join(shareroot, name, 'manifest.json')
|
||||
if not os.path.isfile(path):
|
||||
continue
|
||||
with open(path) as handle:
|
||||
manifest = json.load(handle)
|
||||
yield name, 'runtime', manifest
|
||||
|
||||
|
||||
def load_manifest_file(path):
|
||||
"""Parse a single manifest.json / preinstall.json file."""
|
||||
with open(path) as handle:
|
||||
return json.load(handle)
|
||||
13
plugins/geenforce/manifest.json
Normal file
13
plugins/geenforce/manifest.json
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "geenforce",
|
||||
"version": "0.1.0",
|
||||
"description": "GE-Enforce manifest store. Owns imaging PC-type scopes and their install manifests (apps, scripts, files, registry, version gates) as shopdb data, served to the GE-Enforce client as JSON. Requires GE-Enforce lib >= 2.6 on target PCs (the _CmmVersion gate).",
|
||||
"author": "ShopDB Team",
|
||||
"dependencies": [],
|
||||
"core_version": ">=0.7.0,<1.0.0",
|
||||
"api_prefix": "/api/geenforce",
|
||||
"default_enabled": false,
|
||||
"provides": {
|
||||
"features": ["manifest-store", "imaging-pc-types"]
|
||||
}
|
||||
}
|
||||
14
plugins/geenforce/migrations/env.py
Normal file
14
plugins/geenforce/migrations/env.py
Normal file
@@ -0,0 +1,14 @@
|
||||
"""Alembic environment for the GE-Enforce plugin migration chain.
|
||||
|
||||
Delegates to the shared runner in shopdb.plugins.alembic_template, which filters
|
||||
the metadata to this plugin's tables and drives Alembic against the per-plugin
|
||||
version table alembic_version_geenforce (ADR-008). This plugin is NEW: its 0001
|
||||
baseline really CREATES its tables, because the core chain never built them.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ['PLUGIN_NAME'] = 'geenforce'
|
||||
|
||||
from shopdb.plugins.alembic_template import run_migrations # noqa: E402
|
||||
|
||||
run_migrations()
|
||||
24
plugins/geenforce/migrations/script.py.mako
Normal file
24
plugins/geenforce/migrations/script.py.mako
Normal file
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = ${repr(up_revision)}
|
||||
down_revision = ${repr(down_revision)}
|
||||
branch_labels = ${repr(branch_labels)}
|
||||
depends_on = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade():
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade():
|
||||
${downgrades if downgrades else "pass"}
|
||||
198
plugins/geenforce/migrations/versions/0001_geenforce_baseline.py
Normal file
198
plugins/geenforce/migrations/versions/0001_geenforce_baseline.py
Normal file
@@ -0,0 +1,198 @@
|
||||
"""geenforce plugin baseline (real create).
|
||||
|
||||
This plugin was built after the ADR-008 ownership cutover, so this baseline
|
||||
actually CREATES the plugin's tables (the core chain never knew about them). It
|
||||
runs from `flask plugin install geenforce` / `flask plugin upgrade-all` after
|
||||
`flask db upgrade` builds the core schema. All foreign keys are intra-plugin, so
|
||||
tables are created parent-first. computertypeid / measuringtooltypeid /
|
||||
publishedby are SOFT references (plain columns, no FK) to optional plugins/core.
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = 'geenforce0001baseline'
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
op.create_table(
|
||||
'manifestscopes',
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopename', sa.String(length=64), nullable=False),
|
||||
sa.Column('phase', sa.String(length=16), nullable=False),
|
||||
sa.Column('computertypeid', sa.Integer(), nullable=True),
|
||||
sa.Column('measuringtooltypeid', sa.Integer(), nullable=True),
|
||||
sa.Column('manifestversion', sa.String(length=16), nullable=False),
|
||||
sa.Column('description', sa.String(length=255), nullable=True),
|
||||
sa.Column('topcomment', sa.Text(), nullable=True),
|
||||
sa.Column('site', sa.String(length=100), nullable=True),
|
||||
sa.Column('iscommon', sa.Boolean(), nullable=False),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('scopeid'),
|
||||
sa.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
|
||||
)
|
||||
op.create_table(
|
||||
'manifestentries',
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=128), nullable=False),
|
||||
sa.Column('entrytype', sa.String(length=16), nullable=False),
|
||||
sa.Column('comment', sa.Text(), nullable=True),
|
||||
sa.Column('installer', sa.String(length=255), nullable=True),
|
||||
sa.Column('installargs', sa.Text(), nullable=True),
|
||||
sa.Column('scriptpath', sa.String(length=255), nullable=True),
|
||||
sa.Column('scriptargs', sa.String(length=255), nullable=True),
|
||||
sa.Column('sourcepath', sa.String(length=255), nullable=True),
|
||||
sa.Column('destination', sa.String(length=255), nullable=True),
|
||||
sa.Column('regpath', sa.String(length=255), nullable=True),
|
||||
sa.Column('regname', sa.String(length=128), nullable=True),
|
||||
sa.Column('regvalue', sa.Text(), nullable=True),
|
||||
sa.Column('regtype', sa.String(length=16), nullable=True),
|
||||
sa.Column('payloadsource', sa.String(length=8), nullable=False),
|
||||
sa.Column('payloadref', sa.String(length=512), nullable=True),
|
||||
sa.Column('payloadsha256', sa.String(length=64), nullable=True),
|
||||
sa.Column('detectionmethod', sa.String(length=16), nullable=True),
|
||||
sa.Column('detectionpath', sa.String(length=255), nullable=True),
|
||||
sa.Column('detectionname', sa.String(length=128), nullable=True),
|
||||
sa.Column('detectionvalue', sa.String(length=255), nullable=True),
|
||||
sa.Column('detectionpattern', sa.String(length=255), nullable=True),
|
||||
sa.Column('cmmversion', sa.String(length=16), nullable=True),
|
||||
sa.Column('logfile', sa.String(length=255), nullable=True),
|
||||
sa.Column('waittimeoutsec', sa.Integer(), nullable=True),
|
||||
sa.Column('applymode', sa.String(length=16), nullable=True),
|
||||
sa.Column('updatewindow', sa.String(length=11), nullable=True),
|
||||
sa.Column('preenrollment', sa.Boolean(), nullable=False),
|
||||
sa.Column('killafterdetection', sa.Boolean(), nullable=False),
|
||||
sa.Column('pctypesstrict', sa.Boolean(), nullable=False),
|
||||
sa.Column('createddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('modifieddate', sa.DateTime(), nullable=False),
|
||||
sa.Column('isactive', sa.Boolean(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entryid'),
|
||||
sa.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
|
||||
)
|
||||
op.create_index('idx_entry_scope', 'manifestentries', ['scopeid'])
|
||||
op.create_index('idx_entry_scope_order', 'manifestentries',
|
||||
['scopeid', 'sortorder'])
|
||||
op.create_table(
|
||||
'manifestentrypctypes',
|
||||
sa.Column('entrypctypeid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('pctypevalue', sa.String(length=64), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entrypctypeid'),
|
||||
)
|
||||
op.create_index('idx_pctype_entry', 'manifestentrypctypes', ['entryid'])
|
||||
op.create_table(
|
||||
'manifestentryhostnames',
|
||||
sa.Column('entryhostnameid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('hostnamepattern', sa.String(length=64), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entryhostnameid'),
|
||||
)
|
||||
op.create_index('idx_hostname_entry', 'manifestentryhostnames', ['entryid'])
|
||||
op.create_table(
|
||||
'manifestentrymachinenumbers',
|
||||
sa.Column('entrymachinenumberid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('machinenumber', sa.String(length=16), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('entrymachinenumberid'),
|
||||
)
|
||||
op.create_index('idx_machinenumber_entry', 'manifestentrymachinenumbers',
|
||||
['entryid'])
|
||||
op.create_table(
|
||||
'manifestinusechecks',
|
||||
sa.Column('inusecheckid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('behavior', sa.String(length=20), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('inusecheckid'),
|
||||
sa.UniqueConstraint('entryid'),
|
||||
)
|
||||
op.create_table(
|
||||
'manifestinusecheckprocesses',
|
||||
sa.Column('inusecheckprocessid', sa.Integer(), nullable=False),
|
||||
sa.Column('inusecheckid', sa.Integer(), nullable=False),
|
||||
sa.Column('sortorder', sa.Integer(), nullable=False),
|
||||
sa.Column('processname', sa.String(length=64), nullable=False),
|
||||
sa.Column('exepath', sa.String(length=255), nullable=True),
|
||||
sa.Column('gracefulclosetimeoutsec', sa.Integer(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['inusecheckid'],
|
||||
['manifestinusechecks.inusecheckid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('inusecheckprocessid'),
|
||||
)
|
||||
op.create_index('idx_process_check', 'manifestinusecheckprocesses',
|
||||
['inusecheckid'])
|
||||
op.create_table(
|
||||
'manifestpublishedversions',
|
||||
sa.Column('publishedversionid', sa.Integer(), nullable=False),
|
||||
sa.Column('scopeid', sa.Integer(), nullable=False),
|
||||
sa.Column('versionnumber', sa.Integer(), nullable=False),
|
||||
sa.Column('manifestjson', sa.Text(length=16777215), nullable=False),
|
||||
sa.Column('publishedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('publishedby', sa.Integer(), nullable=True),
|
||||
sa.Column('iscurrent', sa.Boolean(), nullable=False),
|
||||
sa.Column('notes', sa.String(length=255), nullable=True),
|
||||
sa.ForeignKeyConstraint(['scopeid'], ['manifestscopes.scopeid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('publishedversionid'),
|
||||
sa.UniqueConstraint('scopeid', 'versionnumber',
|
||||
name='uq_published_scope_version'),
|
||||
)
|
||||
op.create_index('idx_published_scope', 'manifestpublishedversions',
|
||||
['scopeid'])
|
||||
op.create_table(
|
||||
'manifestpayloads',
|
||||
sa.Column('payloadid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryid', sa.Integer(), nullable=False),
|
||||
sa.Column('filename', sa.String(length=255), nullable=False),
|
||||
sa.Column('contenttype', sa.String(length=128), nullable=True),
|
||||
sa.Column('payloadbytes', sa.LargeBinary(length=16777215), nullable=False),
|
||||
sa.Column('payloadsha256', sa.String(length=64), nullable=False),
|
||||
sa.Column('uploadedat', sa.DateTime(), nullable=False),
|
||||
sa.ForeignKeyConstraint(['entryid'], ['manifestentries.entryid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('payloadid'),
|
||||
)
|
||||
op.create_index('idx_payload_entry', 'manifestpayloads', ['entryid'])
|
||||
op.create_table(
|
||||
'pctypealiases',
|
||||
sa.Column('aliasid', sa.Integer(), nullable=False),
|
||||
sa.Column('aliasgroup', sa.Integer(), nullable=False),
|
||||
sa.Column('aliasname', sa.String(length=64), nullable=False),
|
||||
sa.PrimaryKeyConstraint('aliasid'),
|
||||
sa.UniqueConstraint('aliasgroup', 'aliasname',
|
||||
name='uq_alias_group_name'),
|
||||
)
|
||||
op.create_index('idx_alias_group', 'pctypealiases', ['aliasgroup'])
|
||||
|
||||
|
||||
def downgrade():
|
||||
op.drop_table('pctypealiases')
|
||||
op.drop_table('manifestpayloads')
|
||||
op.drop_table('manifestpublishedversions')
|
||||
op.drop_table('manifestinusecheckprocesses')
|
||||
op.drop_table('manifestinusechecks')
|
||||
op.drop_table('manifestentrymachinenumbers')
|
||||
op.drop_table('manifestentryhostnames')
|
||||
op.drop_table('manifestentrypctypes')
|
||||
op.drop_table('manifestentries')
|
||||
op.drop_table('manifestscopes')
|
||||
41
plugins/geenforce/models/__init__.py
Normal file
41
plugins/geenforce/models/__init__.py
Normal file
@@ -0,0 +1,41 @@
|
||||
"""GE-Enforce plugin models."""
|
||||
|
||||
from .manifest import (
|
||||
ManifestScope,
|
||||
ManifestEntry,
|
||||
ManifestEntryPcType,
|
||||
ManifestEntryHostname,
|
||||
ManifestEntryMachineNumber,
|
||||
ManifestInUseCheck,
|
||||
ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion,
|
||||
ManifestPayload,
|
||||
PcTypeAlias,
|
||||
PHASES,
|
||||
ENTRY_TYPES,
|
||||
REG_TYPES,
|
||||
PAYLOAD_SOURCES,
|
||||
DETECTION_METHODS,
|
||||
APPLY_MODES,
|
||||
INUSE_BEHAVIORS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'ManifestScope',
|
||||
'ManifestEntry',
|
||||
'ManifestEntryPcType',
|
||||
'ManifestEntryHostname',
|
||||
'ManifestEntryMachineNumber',
|
||||
'ManifestInUseCheck',
|
||||
'ManifestInUseCheckProcess',
|
||||
'ManifestPublishedVersion',
|
||||
'ManifestPayload',
|
||||
'PcTypeAlias',
|
||||
'PHASES',
|
||||
'ENTRY_TYPES',
|
||||
'REG_TYPES',
|
||||
'PAYLOAD_SOURCES',
|
||||
'DETECTION_METHODS',
|
||||
'APPLY_MODES',
|
||||
'INUSE_BEHAVIORS',
|
||||
]
|
||||
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'),
|
||||
)
|
||||
94
plugins/geenforce/parity.py
Normal file
94
plugins/geenforce/parity.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""P1 behavioral-parity harness (Gate A).
|
||||
|
||||
Proves the model is lossless WITHOUT byte-diffing: for each manifest, import it
|
||||
into in-memory rows and render it back out, then check that
|
||||
|
||||
1. every entry is field-for-field identical (engine-relevant fields only,
|
||||
dropping _comment and key order), in the same order, AND
|
||||
2. the same entries fire in the same order for a set of machine-profile
|
||||
fixtures, using the same filter logic the engine uses (filters.py).
|
||||
|
||||
Runs with no database (build_scope produces detached ORM objects whose
|
||||
relationship lists are read directly), so it is cheap to re-run and wrap in CI.
|
||||
Output is one readable line per scope.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
|
||||
from .importer import build_scope
|
||||
from .serializer import scope_to_manifest, canonical_entry
|
||||
from .filters import applicable_entry_names
|
||||
|
||||
_FIXTURES_PATH = os.path.join(os.path.dirname(__file__), 'parityfixtures.json')
|
||||
|
||||
|
||||
def load_fixtures():
|
||||
with open(_FIXTURES_PATH) as handle:
|
||||
return json.load(handle)
|
||||
|
||||
|
||||
def check_scope(scopename, phase, original, fixtures):
|
||||
"""Compare an original manifest against its import+export round-trip."""
|
||||
rebuilt = scope_to_manifest(build_scope(scopename, phase, original))
|
||||
orig_apps = original.get('Applications') or []
|
||||
rebuilt_apps = rebuilt.get('Applications') or []
|
||||
|
||||
# Check 1: field-identical, order-preserving.
|
||||
identical = 0
|
||||
firstdiff = None
|
||||
if len(orig_apps) != len(rebuilt_apps):
|
||||
firstdiff = (f'entry count {len(orig_apps)} vs {len(rebuilt_apps)}')
|
||||
for i in range(min(len(orig_apps), len(rebuilt_apps))):
|
||||
co = canonical_entry(orig_apps[i])
|
||||
cr = canonical_entry(rebuilt_apps[i])
|
||||
if co == cr:
|
||||
identical += 1
|
||||
elif firstdiff is None:
|
||||
name = orig_apps[i].get('Name', f'#{i}')
|
||||
diffkeys = sorted(
|
||||
k for k in set(co) | set(cr) if co.get(k) != cr.get(k))
|
||||
firstdiff = f'entry "{name}" differs on {diffkeys}'
|
||||
|
||||
# Check 2: same entries fire in the same order for each profile.
|
||||
profiles_same = 0
|
||||
for profile in fixtures:
|
||||
if (applicable_entry_names(orig_apps, profile)
|
||||
== applicable_entry_names(rebuilt_apps, profile)):
|
||||
profiles_same += 1
|
||||
elif firstdiff is None:
|
||||
firstdiff = f'filter mismatch for profile {profile.get("label")}'
|
||||
|
||||
passed = (identical == len(orig_apps) == len(rebuilt_apps)
|
||||
and profiles_same == len(fixtures))
|
||||
return {
|
||||
'scopename': scopename,
|
||||
'phase': phase,
|
||||
'entries_total': len(orig_apps),
|
||||
'entries_identical': identical,
|
||||
'profiles_total': len(fixtures),
|
||||
'profiles_same': profiles_same,
|
||||
'passed': passed,
|
||||
'firstdiff': firstdiff,
|
||||
}
|
||||
|
||||
|
||||
def run_parity(manifests, fixtures=None):
|
||||
"""Run parity over a list of (scopename, phase, manifest_dict). Returns
|
||||
(results, all_passed)."""
|
||||
fixtures = fixtures if fixtures is not None else load_fixtures()
|
||||
results = [check_scope(name, phase, manifest, fixtures)
|
||||
for name, phase, manifest in manifests]
|
||||
return results, all(r['passed'] for r in results)
|
||||
|
||||
|
||||
def format_result(result):
|
||||
"""One human-readable line for a scope result."""
|
||||
status = 'PASS' if result['passed'] else 'FAIL'
|
||||
line = (f"scope {result['scopename']:<28} "
|
||||
f"entries {result['entries_identical']}/{result['entries_total']} identical "
|
||||
f"profiles {result['profiles_same']}/{result['profiles_total']} same-fire "
|
||||
f"{status}")
|
||||
if not result['passed'] and result['firstdiff']:
|
||||
line += f"\n -> {result['firstdiff']}"
|
||||
return line
|
||||
20
plugins/geenforce/parityfixtures.json
Normal file
20
plugins/geenforce/parityfixtures.json
Normal file
@@ -0,0 +1,20 @@
|
||||
[
|
||||
{"label": "collections-okuma-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0615", "machinenumber": "3201", "cmmversion": ""},
|
||||
{"label": "collections-mtconnect-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC7501", "machinenumber": "7501", "cmmversion": ""},
|
||||
{"label": "collections-other-bay", "pctype": "gea-shopfloor-collections", "subtype": "Machine", "hostname": "WJPC0001", "machinenumber": "0001", "cmmversion": ""},
|
||||
{"label": "nocollections", "pctype": "gea-shopfloor-nocollections", "subtype": "", "hostname": "WJPC2000", "machinenumber": "", "cmmversion": ""},
|
||||
{"label": "common-timeclock", "pctype": "gea-shopfloor-common", "subtype": "", "hostname": "WJPC3000", "machinenumber": "", "cmmversion": ""},
|
||||
{"label": "cmm-2016", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM01", "machinenumber": "9101", "cmmversion": "2016"},
|
||||
{"label": "cmm-2019", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM02", "machinenumber": "9102", "cmmversion": "2019"},
|
||||
{"label": "cmm-2026", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM11", "machinenumber": "9111", "cmmversion": "2026"},
|
||||
{"label": "cmm-noversion-legacy", "pctype": "gea-shopfloor-cmm", "subtype": "", "hostname": "WJCMM99", "machinenumber": "9199", "cmmversion": ""},
|
||||
{"label": "keyence-vr6000", "pctype": "Keyence", "subtype": "vr6000", "hostname": "WJKEY01", "machinenumber": "8001", "cmmversion": ""},
|
||||
{"label": "keyence-vr3000", "pctype": "Keyence", "subtype": "vr3000", "hostname": "WJKEY02", "machinenumber": "8002", "cmmversion": ""},
|
||||
{"label": "genspect", "pctype": "gea-shopfloor-genspect", "subtype": "", "hostname": "WJGEN01", "machinenumber": "8100", "cmmversion": ""},
|
||||
{"label": "heattreat", "pctype": "gea-shopfloor-heattreat", "subtype": "", "hostname": "WJHT01", "machinenumber": "8200", "cmmversion": ""},
|
||||
{"label": "waxtrace", "pctype": "gea-shopfloor-waxtrace", "subtype": "", "hostname": "WJWAX01", "machinenumber": "8300", "cmmversion": ""},
|
||||
{"label": "partmarker", "pctype": "gea-shopfloor-partmarker", "subtype": "", "hostname": "WJPM01", "machinenumber": "8400", "cmmversion": ""},
|
||||
{"label": "display", "pctype": "gea-shopfloor-display", "subtype": "", "hostname": "WJDISP01", "machinenumber": "", "cmmversion": ""},
|
||||
{"label": "legacy-standard-machine", "pctype": "Standard", "subtype": "Machine", "hostname": "WJLEG01", "machinenumber": "3202", "cmmversion": ""},
|
||||
{"label": "legacy-cmm-name", "pctype": "CMM", "subtype": "", "hostname": "WJLEG02", "machinenumber": "9102", "cmmversion": "2019"}
|
||||
]
|
||||
178
plugins/geenforce/plugin.py
Normal file
178
plugins/geenforce/plugin.py
Normal file
@@ -0,0 +1,178 @@
|
||||
"""GE-Enforce manifest-store plugin.
|
||||
|
||||
Owns the imaging-PC-type scopes and their install manifests as shopdb data
|
||||
(see docs/proposals/ge-enforce-plugin.md). This is the P0/P1 foundation: models,
|
||||
the alias-graph seed, and the client-facing manifest endpoint. Authoring UI and
|
||||
client cutover come in later phases.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import List, Dict, Optional, Type
|
||||
|
||||
import click
|
||||
from flask import Flask, Blueprint
|
||||
|
||||
from shopdb.plugins.base import BasePlugin, PluginMeta
|
||||
from shopdb.api import db
|
||||
|
||||
from .api import geenforce_bp
|
||||
from .models import (
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType, ManifestEntryHostname,
|
||||
ManifestEntryMachineNumber, ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion, ManifestPayload, PcTypeAlias,
|
||||
)
|
||||
from .filters import ALIAS_GROUPS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class GeEnforcePlugin(BasePlugin):
|
||||
"""Desired-state manifest store for the GE-Enforce client."""
|
||||
|
||||
def __init__(self):
|
||||
self._manifest = self._load_manifest()
|
||||
|
||||
def _load_manifest(self) -> Dict:
|
||||
manifest_path = Path(__file__).parent / 'manifest.json'
|
||||
if manifest_path.exists():
|
||||
with open(manifest_path, 'r') as handle:
|
||||
return json.load(handle)
|
||||
return {}
|
||||
|
||||
@property
|
||||
def meta(self) -> PluginMeta:
|
||||
return PluginMeta(
|
||||
name=self._manifest.get('name', 'geenforce'),
|
||||
version=self._manifest.get('version', '0.1.0'),
|
||||
description=self._manifest.get('description', 'GE-Enforce manifest store'),
|
||||
author=self._manifest.get('author', 'ShopDB Team'),
|
||||
dependencies=self._manifest.get('dependencies', []),
|
||||
core_version=self._manifest.get('core_version', '>=0.7.0,<1.0.0'),
|
||||
api_prefix=self._manifest.get('api_prefix', '/api/geenforce'),
|
||||
)
|
||||
|
||||
def get_blueprint(self) -> Optional[Blueprint]:
|
||||
return geenforce_bp
|
||||
|
||||
def get_models(self) -> List[Type]:
|
||||
return [
|
||||
ManifestScope, ManifestEntry, ManifestEntryPcType,
|
||||
ManifestEntryHostname, ManifestEntryMachineNumber,
|
||||
ManifestInUseCheck, ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion, ManifestPayload, PcTypeAlias,
|
||||
]
|
||||
|
||||
def get_permissions(self) -> List:
|
||||
"""RBAC permissions this plugin owns (edit vs ship are split)."""
|
||||
return [
|
||||
('geenforce.manage', 'Edit imaging PC types and manifest drafts',
|
||||
'geenforce'),
|
||||
('geenforce.publish', 'Publish, roll back, and export manifests',
|
||||
'geenforce'),
|
||||
('geenforce.fetch', 'Fetch published manifests (client service token)',
|
||||
'geenforce'),
|
||||
]
|
||||
|
||||
def init_app(self, app: Flask, db_instance) -> None:
|
||||
logger.info(f"GE-Enforce plugin initialized (v{self.meta.version})")
|
||||
|
||||
def on_install(self, app: Flask) -> None:
|
||||
with app.app_context():
|
||||
self._seed_aliases()
|
||||
db.session.commit()
|
||||
logger.info("GE-Enforce plugin installed")
|
||||
|
||||
def _seed_aliases(self) -> None:
|
||||
"""Seed pctypealiases from the engine lib's alias graph (idempotent)."""
|
||||
for group_index, group in enumerate(ALIAS_GROUPS):
|
||||
for aliasname in group:
|
||||
exists = PcTypeAlias.query.filter_by(
|
||||
aliasgroup=group_index, aliasname=aliasname).first()
|
||||
if not exists:
|
||||
db.session.add(PcTypeAlias(
|
||||
aliasgroup=group_index, aliasname=aliasname))
|
||||
|
||||
def get_cli_commands(self) -> List:
|
||||
"""CLI: parity (Gate A), import-share, export-share, publish."""
|
||||
|
||||
@click.group('geenforce')
|
||||
def geenforce_cli():
|
||||
"""GE-Enforce manifest-store commands."""
|
||||
|
||||
@geenforce_cli.command('parity')
|
||||
@click.option('--shareroot', required=True,
|
||||
help='GE-Enforce share root (contains common/, '
|
||||
'gea-shopfloor-*/).')
|
||||
@click.option('--preinstall', default=None,
|
||||
help='Optional path to a preinstall.json to include.')
|
||||
def parity_cmd(shareroot, preinstall):
|
||||
"""Prove import+export is behaviorally lossless (Gate A). DB-free."""
|
||||
from .importer import discover_share, load_manifest_file
|
||||
from .parity import run_parity, format_result
|
||||
|
||||
manifests = list(discover_share(shareroot))
|
||||
if preinstall:
|
||||
manifests.append(
|
||||
('preinstall', 'preinstall', load_manifest_file(preinstall)))
|
||||
results, ok = run_parity(manifests)
|
||||
for result in results:
|
||||
click.echo(format_result(result))
|
||||
click.echo(f"RESULT: {'PASS' if ok else 'FAIL'} "
|
||||
f"({len(results)} scopes)")
|
||||
raise SystemExit(0 if ok else 1)
|
||||
|
||||
@geenforce_cli.command('import-share')
|
||||
@click.option('--shareroot', required=True)
|
||||
@click.option('--preinstall', default=None)
|
||||
@click.option('--scope', default=None,
|
||||
help='Import only this scope name (else all).')
|
||||
def import_share_cmd(shareroot, preinstall, scope):
|
||||
"""Import on-share manifests into draft rows (idempotent rebuild)."""
|
||||
from flask import current_app
|
||||
from .importer import discover_share, load_manifest_file, build_scope
|
||||
from .service import replace_scope_draft
|
||||
|
||||
with current_app.app_context():
|
||||
sources = list(discover_share(shareroot))
|
||||
if preinstall:
|
||||
sources.append(('preinstall', 'preinstall',
|
||||
load_manifest_file(preinstall)))
|
||||
count = 0
|
||||
for name, phase, manifest in sources:
|
||||
if scope and name != scope:
|
||||
continue
|
||||
replace_scope_draft(name, phase, manifest)
|
||||
count += 1
|
||||
db.session.commit()
|
||||
click.echo(f"Imported {count} scope(s).")
|
||||
|
||||
@geenforce_cli.command('publish')
|
||||
@click.argument('scopename')
|
||||
@click.option('--phase', default='runtime')
|
||||
@click.option('--notes', default=None)
|
||||
def publish_cmd(scopename, phase, notes):
|
||||
"""Freeze the current draft of a scope into a published snapshot."""
|
||||
from flask import current_app
|
||||
from .service import publish_scope
|
||||
|
||||
with current_app.app_context():
|
||||
version = publish_scope(scopename, phase, notes=notes)
|
||||
db.session.commit()
|
||||
click.echo(f"Published {scopename}/{phase} as v{version}.")
|
||||
|
||||
@geenforce_cli.command('export-share')
|
||||
@click.argument('scopename')
|
||||
@click.option('--shareroot', required=True)
|
||||
@click.option('--phase', default='runtime')
|
||||
def export_share_cmd(scopename, shareroot, phase):
|
||||
"""Write a scope's published JSON to the share (with history backup)."""
|
||||
from flask import current_app
|
||||
from .service import export_scope_to_share
|
||||
|
||||
with current_app.app_context():
|
||||
path = export_scope_to_share(scopename, phase, shareroot)
|
||||
click.echo(f"Exported to {path}.")
|
||||
|
||||
return [geenforce_cli]
|
||||
133
plugins/geenforce/serializer.py
Normal file
133
plugins/geenforce/serializer.py
Normal file
@@ -0,0 +1,133 @@
|
||||
"""Rebuild manifest JSON from DB rows, and canonicalize entries for parity.
|
||||
|
||||
`scope_to_manifest` renders a ManifestScope back into the exact JSON structure
|
||||
GE-Enforce consumes (Version / _comment / Applications, plus Site for
|
||||
preinstall). `canonical_entry` reduces an Applications[] entry (from either the
|
||||
on-share file or a rebuilt scope) to only the fields the engine reads, dropping
|
||||
`_comment` and key order, so the parity harness can compare behavior not bytes.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
# Scalar entry fields: model attribute -> manifest key. Emitted only when set,
|
||||
# so a rebuilt entry has the same sparse key presence as the original.
|
||||
_SCALAR_FIELDS = [
|
||||
('name', 'Name'),
|
||||
('entrytype', 'Type'),
|
||||
('installer', 'Installer'),
|
||||
('installargs', 'InstallArgs'),
|
||||
('scriptpath', 'Script'),
|
||||
('scriptargs', 'Args'),
|
||||
('sourcepath', 'Source'),
|
||||
('destination', 'Destination'),
|
||||
('regpath', 'RegPath'),
|
||||
('regname', 'RegName'),
|
||||
('regtype', 'RegType'),
|
||||
('detectionmethod', 'DetectionMethod'),
|
||||
('detectionpath', 'DetectionPath'),
|
||||
('detectionname', 'DetectionName'),
|
||||
('detectionvalue', 'DetectionValue'),
|
||||
('detectionpattern', 'DetectionPattern'),
|
||||
('cmmversion', '_CmmVersion'),
|
||||
('logfile', 'LogFile'),
|
||||
('waittimeoutsec', 'WaitTimeoutSec'),
|
||||
('applymode', 'ApplyMode'),
|
||||
('updatewindow', 'UpdateWindow'),
|
||||
]
|
||||
|
||||
# Boolean flags emitted only when True (matches how preinstall.json carries them).
|
||||
_BOOL_FLAGS = [
|
||||
('preenrollment', 'PreEnrollment'),
|
||||
('killafterdetection', 'KillAfterDetection'),
|
||||
('pctypesstrict', 'PCTypesStrict'),
|
||||
]
|
||||
|
||||
# The complete set of engine-relevant keys the parity check compares.
|
||||
ENGINE_KEYS = (
|
||||
[mk for _, mk in _SCALAR_FIELDS]
|
||||
+ [mk for _, mk in _BOOL_FLAGS]
|
||||
+ ['PCTypes', 'TargetHostnames', 'TargetMachineNumbers', 'RegValue', 'InUseCheck']
|
||||
)
|
||||
|
||||
|
||||
def entry_to_dict(entry):
|
||||
"""Render a ManifestEntry model back into a manifest Applications[] entry."""
|
||||
result = {}
|
||||
if entry.comment:
|
||||
result['_comment'] = entry.comment
|
||||
for attr, key in _SCALAR_FIELDS:
|
||||
value = getattr(entry, attr)
|
||||
if value is not None and value != '':
|
||||
result[key] = value
|
||||
# RegValue: stored as a raw JSON literal, reconstitute its real type.
|
||||
if entry.regvalue is not None:
|
||||
result['RegValue'] = json.loads(entry.regvalue)
|
||||
# Multi-value filters (only when present).
|
||||
if entry.pctypes:
|
||||
result['PCTypes'] = [p.pctypevalue for p in entry.pctypes]
|
||||
if entry.hostnames:
|
||||
result['TargetHostnames'] = [h.hostnamepattern for h in entry.hostnames]
|
||||
if entry.machinenumbers:
|
||||
result['TargetMachineNumbers'] = [m.machinenumber
|
||||
for m in entry.machinenumbers]
|
||||
for attr, key in _BOOL_FLAGS:
|
||||
if getattr(entry, attr):
|
||||
result[key] = True
|
||||
# InUseCheck (nested object + Processes[]).
|
||||
if entry.inusecheck:
|
||||
procs = []
|
||||
for proc in entry.inusecheck.processes:
|
||||
pd = {'Name': proc.processname}
|
||||
if proc.exepath is not None:
|
||||
pd['ExePath'] = proc.exepath
|
||||
if proc.gracefulclosetimeoutsec is not None:
|
||||
pd['GracefulCloseTimeoutSec'] = proc.gracefulclosetimeoutsec
|
||||
procs.append(pd)
|
||||
result['InUseCheck'] = {
|
||||
'Behavior': entry.inusecheck.behavior,
|
||||
'Processes': procs,
|
||||
}
|
||||
return result
|
||||
|
||||
|
||||
def scope_to_manifest(scope):
|
||||
"""Render a ManifestScope back into a full manifest.json structure."""
|
||||
manifest = {'Version': scope.manifestversion}
|
||||
if scope.topcomment:
|
||||
manifest['_comment'] = scope.topcomment
|
||||
if scope.site:
|
||||
manifest['Site'] = scope.site
|
||||
manifest['Applications'] = [entry_to_dict(e) for e in scope.entries]
|
||||
return manifest
|
||||
|
||||
|
||||
def scope_to_json(scope, indent=2):
|
||||
"""The serialized JSON text a client/export would receive."""
|
||||
return json.dumps(scope_to_manifest(scope), indent=indent)
|
||||
|
||||
|
||||
def canonical_entry(entry_dict):
|
||||
"""Reduce an Applications[] entry to engine-relevant fields for parity.
|
||||
|
||||
Drops `_comment` and key order. Normalizes InUseCheck and the process list
|
||||
so two entries that behave identically compare equal regardless of source.
|
||||
"""
|
||||
canon = {}
|
||||
for key in ENGINE_KEYS:
|
||||
if key == 'InUseCheck':
|
||||
continue
|
||||
if key in entry_dict and entry_dict[key] is not None and entry_dict[key] != '':
|
||||
canon[key] = entry_dict[key]
|
||||
inuse = entry_dict.get('InUseCheck')
|
||||
if inuse:
|
||||
procs = []
|
||||
for proc in inuse.get('Processes', []):
|
||||
pd = {'Name': proc.get('Name')}
|
||||
if proc.get('ExePath') is not None:
|
||||
pd['ExePath'] = proc.get('ExePath')
|
||||
if proc.get('GracefulCloseTimeoutSec') is not None:
|
||||
pd['GracefulCloseTimeoutSec'] = proc.get('GracefulCloseTimeoutSec')
|
||||
procs.append(pd)
|
||||
canon['InUseCheck'] = {'Behavior': inuse.get('Behavior'),
|
||||
'Processes': procs}
|
||||
return canon
|
||||
121
plugins/geenforce/service.py
Normal file
121
plugins/geenforce/service.py
Normal file
@@ -0,0 +1,121 @@
|
||||
"""DB-touching operations for the manifest store.
|
||||
|
||||
Kept out of the CLI and routes so both share one implementation:
|
||||
- `replace_scope_draft` imports/re-imports a scope's DRAFT entries WITHOUT
|
||||
touching its published-version history (re-import is idempotent + safe).
|
||||
- `publish_scope` freezes the current draft into a new immutable snapshot.
|
||||
- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle.
|
||||
"""
|
||||
|
||||
import os
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from shopdb.api import db
|
||||
|
||||
from .models import ManifestScope, ManifestPublishedVersion
|
||||
from .importer import build_entry
|
||||
from .serializer import scope_to_json
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def replace_scope_draft(scopename, phase, manifest):
|
||||
"""Create/refresh a scope and REPLACE its draft entries. Published versions
|
||||
are left untouched. Returns the scope (uncommitted)."""
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
scope = ManifestScope(scopename=scopename, phase=phase)
|
||||
db.session.add(scope)
|
||||
scope.manifestversion = str(manifest.get('Version', '1.0'))
|
||||
scope.topcomment = manifest.get('_comment')
|
||||
scope.site = manifest.get('Site')
|
||||
scope.iscommon = (scopename == 'common')
|
||||
|
||||
for entry in list(scope.entries):
|
||||
db.session.delete(entry)
|
||||
db.session.flush()
|
||||
|
||||
for i, entry_dict in enumerate(manifest.get('Applications') or []):
|
||||
scope.entries.append(build_entry(entry_dict, i))
|
||||
return scope
|
||||
|
||||
|
||||
def publish_scope(scopename, phase, notes=None, publishedby=None):
|
||||
"""Freeze the current draft into a new published snapshot. Returns the
|
||||
version number (uncommitted)."""
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
raise ValueError(f'No scope {scopename}/{phase}')
|
||||
|
||||
text = scope_to_json(scope)
|
||||
maxversion = db.session.query(
|
||||
func.max(ManifestPublishedVersion.versionnumber)
|
||||
).filter_by(scopeid=scope.scopeid).scalar() or 0
|
||||
|
||||
ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scope.scopeid, iscurrent=True
|
||||
).update({'iscurrent': False})
|
||||
|
||||
db.session.add(ManifestPublishedVersion(
|
||||
scopeid=scope.scopeid,
|
||||
versionnumber=maxversion + 1,
|
||||
manifestjson=text,
|
||||
publishedat=_utcnow(),
|
||||
publishedby=publishedby,
|
||||
iscurrent=True,
|
||||
notes=notes))
|
||||
return maxversion + 1
|
||||
|
||||
|
||||
def rollback_scope(scopename, phase, versionnumber):
|
||||
"""Make an older published version current again (uncommitted)."""
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
raise ValueError(f'No scope {scopename}/{phase}')
|
||||
target = ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scope.scopeid, versionnumber=versionnumber).first()
|
||||
if not target:
|
||||
raise ValueError(f'No version {versionnumber} for {scopename}')
|
||||
ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scope.scopeid, iscurrent=True
|
||||
).update({'iscurrent': False})
|
||||
target.iscurrent = True
|
||||
return versionnumber
|
||||
|
||||
|
||||
def export_scope_to_share(scopename, phase, shareroot):
|
||||
"""Write a scope's current published JSON to the share, backing up the old
|
||||
file to _meta/history first. Returns the written path."""
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
raise ValueError(f'No scope {scopename}/{phase}')
|
||||
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
if not published:
|
||||
raise ValueError(f'{scopename} has no published version')
|
||||
|
||||
if phase == 'preinstall':
|
||||
target = os.path.join(shareroot, 'preinstall.json')
|
||||
else:
|
||||
target = os.path.join(shareroot, scopename, 'manifest.json')
|
||||
os.makedirs(os.path.dirname(target), exist_ok=True)
|
||||
|
||||
if os.path.isfile(target):
|
||||
historydir = os.path.join(shareroot, '_meta', 'history')
|
||||
os.makedirs(historydir, exist_ok=True)
|
||||
stamp = _utcnow().strftime('%Y%m%d-%H%M%S')
|
||||
with open(target) as src:
|
||||
old = src.read()
|
||||
with open(os.path.join(historydir, f'{stamp}-{scopename}.json'), 'w') as dst:
|
||||
dst.write(old)
|
||||
|
||||
with open(target, 'w') as handle:
|
||||
handle.write(published.manifestjson)
|
||||
return target
|
||||
Reference in New Issue
Block a user