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:
@@ -9,7 +9,7 @@ The contract is locked in [ADR-001](../docs/adr/ADR-001-asset-as-platform-contra
|
||||
The framework declares its contract version in `shopdb/__init__.py`:
|
||||
|
||||
```python
|
||||
__contract_version__ = '0.10.0'
|
||||
__contract_version__ = '0.11.0'
|
||||
```
|
||||
|
||||
Each plugin's `manifest.json` declares the range of contract versions it supports:
|
||||
@@ -469,7 +469,11 @@ What `shopdb.api` exposes:
|
||||
- Responses: `success_response`, `error_response`, `paginated_response`,
|
||||
`ErrorCodes`
|
||||
- Pagination: `get_pagination_params`, `paginate_query`
|
||||
- Authorization: `require_permission`, `require_role`
|
||||
- Authorization: `require_permission`, `require_role`,
|
||||
`service_token_authorized`
|
||||
(`service_token_authorized(scope)` returns True when the request carries a
|
||||
managed service token scoped for `scope` whose owner holds that permission -
|
||||
for unattended plugin endpoints like the GE-Enforce fetch API)
|
||||
- Helpers: `audit_log`, `resolve_asset_position`, `resolve_dualpath_pairs`,
|
||||
`dualpath_single_machine_enabled`
|
||||
- Import mode: `apply_import_timestamps`, `import_mode_active`,
|
||||
|
||||
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
|
||||
@@ -32,7 +32,11 @@ from .plugins import plugin_manager
|
||||
# catalog. Consumed by full_permission_catalog() (core + enabled plugins),
|
||||
# which backs seeding, the role grid, and API-token scope validation. Additive
|
||||
# optional hook, minor bump.
|
||||
__contract_version__ = '0.10.0'
|
||||
# 0.11.0: added service_token_authorized(scope) to shopdb.api so a plugin's
|
||||
# unattended endpoints (e.g. the GE-Enforce fetch API) can authorize a scoped
|
||||
# managed service token without importing core token internals. Additive name
|
||||
# on the import surface, minor bump.
|
||||
__contract_version__ = '0.11.0'
|
||||
|
||||
# Product release version (see ADR-007). The product version and the
|
||||
# plugin-contract version above are distinct series with independent
|
||||
|
||||
@@ -58,6 +58,11 @@ from shopdb.utils.pagination import get_pagination_params, paginate_query
|
||||
# Authorization decorators for gating plugin write routes
|
||||
from shopdb.utils.authz import require_permission, require_role
|
||||
|
||||
# Service-token authorization for unattended plugin endpoints (collector,
|
||||
# GE-Enforce fetch, ...): checks a scoped managed token without exposing token
|
||||
# internals.
|
||||
from shopdb.utils.apitoken_auth import service_token_authorized
|
||||
|
||||
# Import-mode helpers: preserve legacy timestamps during a bulk data import
|
||||
from shopdb.utils.import_mode import (
|
||||
apply_import_timestamps,
|
||||
@@ -254,6 +259,7 @@ __all__ = [
|
||||
# Authorization decorators
|
||||
'require_permission',
|
||||
'require_role',
|
||||
'service_token_authorized',
|
||||
# Import-mode helpers
|
||||
'apply_import_timestamps',
|
||||
'import_mode_active',
|
||||
|
||||
@@ -42,6 +42,11 @@ PLUGIN_TABLE_OWNERS: dict[str, Iterable[str]] = {
|
||||
'computers': ('computertypes', 'computers', 'computerinstalledapps',
|
||||
'accessprotocols', 'computeraccess'),
|
||||
'employees': ('directoryemployees',),
|
||||
'geenforce': ('manifestscopes', 'manifestentries', 'manifestentrypctypes',
|
||||
'manifestentryhostnames', 'manifestentrymachinenumbers',
|
||||
'manifestinusechecks', 'manifestinusecheckprocesses',
|
||||
'manifestpublishedversions', 'manifestpayloads',
|
||||
'pctypealiases'),
|
||||
'knowledgebase': ('knowledgebase',),
|
||||
'machines': ('machinetypes', 'machines'),
|
||||
'measuringtools': ('measuringtooltypes', 'measuringtools'),
|
||||
|
||||
@@ -76,6 +76,40 @@ def touch_apitoken_lastused(token):
|
||||
db.session.commit()
|
||||
|
||||
|
||||
def service_token_authorized(scope):
|
||||
"""True when the current request carries a managed token scoped for `scope`
|
||||
whose owner is active and holds that permission. Accepts X-API-Key or a
|
||||
Bearer PAT (the before_request shim resolves Bearer into g.apitokenid).
|
||||
Touches lastusedat on success.
|
||||
|
||||
The single contract-surface entry point for unattended SERVICE tokens
|
||||
(collector.ingest, geenforce.fetch, ...), so plugins authorize a service
|
||||
token without reaching into core token internals. Returns False on any
|
||||
miss; the caller returns its own 401.
|
||||
"""
|
||||
from shopdb.core.models import User
|
||||
|
||||
api_key = request.headers.get('X-API-Key')
|
||||
token = None
|
||||
if api_key and api_key.startswith(TOKEN_SECRET_PREFIX):
|
||||
resolved = resolve_api_token(api_key)
|
||||
token = resolved[0] if resolved else None
|
||||
else:
|
||||
tokenid = getattr(g, 'apitokenid', None)
|
||||
if tokenid is not None:
|
||||
token = db.session.get(ApiToken, tokenid)
|
||||
if token is None:
|
||||
return False
|
||||
scopelist = token.scopelist
|
||||
if not scopelist or scope not in scopelist:
|
||||
return False
|
||||
user = db.session.get(User, token.userid)
|
||||
if user is None or not user.isactive or not user.haspermission(scope):
|
||||
return False
|
||||
touch_apitoken_lastused(token)
|
||||
return True
|
||||
|
||||
|
||||
def install_apitoken_auth(app):
|
||||
"""Register the before_request PAT shim on the app."""
|
||||
|
||||
|
||||
@@ -46,6 +46,7 @@ CUTOVER_PLUGINS = (
|
||||
# stamp '<plugin>0001anchor'; measuringtools stamps its real baseline id.
|
||||
EXPECTED_HEAD_REVISION = {plugin: f'{plugin}0001anchor' for plugin in CUTOVER_PLUGINS}
|
||||
EXPECTED_HEAD_REVISION['measuringtools'] = 'measuringtools0001baseline'
|
||||
EXPECTED_HEAD_REVISION['geenforce'] = 'geenforce0001baseline'
|
||||
# machines (renamed from equipment) keeps its original anchor id and adds the
|
||||
# rename revision on top, so its head is not the f-string default.
|
||||
EXPECTED_HEAD_REVISION['machines'] = 'machines0002rename'
|
||||
|
||||
152
tests/test_plugins/test_geenforce_manifest.py
Normal file
152
tests/test_plugins/test_geenforce_manifest.py
Normal file
@@ -0,0 +1,152 @@
|
||||
"""GE-Enforce first slice: import a scope, publish it, serve it to a client.
|
||||
|
||||
Exercises the vertical the plan calls the first slice (via a small synthetic
|
||||
scope rather than the real gea-shopfloor-cmm): draft import -> publish ->
|
||||
GET /api/geenforce/manifest with a geenforce.fetch service token; ETag/304;
|
||||
draft edits never change the served bytes; publish + rollback change them.
|
||||
"""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.geenforce.models import ManifestScope
|
||||
from plugins.geenforce import service
|
||||
|
||||
|
||||
SCOPE = {
|
||||
'Version': '2.6',
|
||||
'Applications': [
|
||||
{'Name': 'Alpha', 'Type': 'MSI', 'Installer': 'apps/alpha.msi',
|
||||
'DetectionMethod': 'Registry', 'DetectionPath': 'HKLM:\\SOFTWARE\\Alpha'},
|
||||
{'Name': 'Beta', 'Type': 'PS1', 'Script': 'scripts/beta.ps1',
|
||||
'DetectionMethod': 'Always'},
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def _seed_and_publish(app, scopename='gea-shopfloor-cmm', manifest=None):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft(scopename, 'runtime', manifest or SCOPE)
|
||||
service.publish_scope(scopename, 'runtime', notes='initial')
|
||||
service.db.session.commit()
|
||||
|
||||
|
||||
def _mint_fetch_token(client, auth_headers):
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'geenforce svc', 'scopes': ['geenforce.fetch']},
|
||||
headers=auth_headers)
|
||||
assert resp.status_code == 201, resp.get_json()
|
||||
return resp.get_json()['data']['secret']
|
||||
|
||||
|
||||
def test_import_publish_serve(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 200, resp.data
|
||||
body = json.loads(resp.data)
|
||||
assert [e['Name'] for e in body['Applications']] == ['Alpha', 'Beta']
|
||||
assert resp.headers.get('ETag')
|
||||
assert resp.headers.get('X-Manifest-Version') == '1'
|
||||
|
||||
|
||||
def test_etag_304(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
first = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
etag = first.headers['ETag']
|
||||
again = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret, 'If-None-Match': etag})
|
||||
assert again.status_code == 304
|
||||
|
||||
|
||||
def test_draft_edit_does_not_change_served_bytes(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
before = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
|
||||
# Edit the DRAFT (add an entry) but do NOT publish.
|
||||
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
|
||||
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
|
||||
service.db.session.commit()
|
||||
|
||||
after = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
assert before == after # served bytes come from the published snapshot only
|
||||
|
||||
|
||||
def test_publish_then_rollback(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
v1 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret}).data
|
||||
|
||||
changed = {'Version': '2.6', 'Applications': SCOPE['Applications'] + [
|
||||
{'Name': 'Gamma', 'Type': 'PS1', 'Script': 'scripts/gamma.ps1'}]}
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-cmm', 'runtime', changed)
|
||||
service.publish_scope('gea-shopfloor-cmm', 'runtime', notes='add gamma')
|
||||
service.db.session.commit()
|
||||
|
||||
v2 = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert v2.headers['X-Manifest-Version'] == '2'
|
||||
assert b'Gamma' in v2.data
|
||||
|
||||
with app.app_context():
|
||||
service.rollback_scope('gea-shopfloor-cmm', 'runtime', 1)
|
||||
service.db.session.commit()
|
||||
|
||||
rolled = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert rolled.data == v1 # byte-identical: published snapshots are frozen text
|
||||
|
||||
|
||||
def test_unauthenticated_rejected(client, db, app):
|
||||
_seed_and_publish(app)
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm')
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
def test_wrong_scope_rejected(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
resp = client.post('/api/apitokens',
|
||||
json={'name': 'wrong', 'scopes': ['collector.ingest']},
|
||||
headers=auth_headers)
|
||||
secret = resp.get_json()['data']['secret']
|
||||
manifest = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-cmm',
|
||||
headers={'X-API-Key': secret})
|
||||
assert manifest.status_code == 401
|
||||
|
||||
|
||||
def test_unpublished_scope_404(client, db, app, auth_headers):
|
||||
with app.app_context():
|
||||
service.replace_scope_draft('gea-shopfloor-keyence', 'runtime', SCOPE)
|
||||
service.db.session.commit() # draft only, never published
|
||||
secret = _mint_fetch_token(client, auth_headers)
|
||||
resp = client.get('/api/geenforce/manifest?pctype=gea-shopfloor-keyence',
|
||||
headers={'X-API-Key': secret})
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
def test_admin_list_and_preview(client, db, app, auth_headers):
|
||||
_seed_and_publish(app)
|
||||
listing = client.get('/api/geenforce/scopes', headers=auth_headers)
|
||||
assert listing.status_code == 200
|
||||
scopes = listing.get_json()['data']
|
||||
cmm = next(s for s in scopes if s['scopename'] == 'gea-shopfloor-cmm')
|
||||
assert cmm['entrycount'] == 2
|
||||
assert cmm['publishedversion'] == 1
|
||||
|
||||
preview = client.get(f"/api/geenforce/scopes/{cmm['scopeid']}/preview",
|
||||
headers=auth_headers)
|
||||
assert preview.status_code == 200
|
||||
assert [e['Name'] for e in preview.get_json()['data']['manifest']['Applications']] \
|
||||
== ['Alpha', 'Beta']
|
||||
142
tests/test_plugins/test_geenforce_parity.py
Normal file
142
tests/test_plugins/test_geenforce_parity.py
Normal file
@@ -0,0 +1,142 @@
|
||||
"""GE-Enforce P1 parity gate (Gate A): import + export is behaviorally lossless.
|
||||
|
||||
Runs the DB-free parity harness (parse -> in-memory rows -> render -> compare)
|
||||
over a synthetic, site-neutral manifest that exercises every entry type,
|
||||
detection method, targeting filter, RegValue typing, InUseCheck, and preinstall
|
||||
flag. The synthetic fixture is what CI gates on (no real site data is vendored
|
||||
into the framework repo). If the real GE-Enforce reference share is present
|
||||
(dev), it is also checked.
|
||||
"""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from plugins.geenforce.parity import run_parity, load_fixtures
|
||||
from plugins.geenforce.importer import discover_share, load_manifest_file
|
||||
|
||||
|
||||
# A site-neutral manifest covering the whole schema surface.
|
||||
SYNTHETIC = {
|
||||
'Version': '2.6',
|
||||
'_comment': 'Synthetic parity fixture (not a real site manifest).',
|
||||
'Applications': [
|
||||
{
|
||||
'_comment': 'MSI with Registry detection, version gate, InUseCheck.',
|
||||
'Name': 'Sample MSI', 'Type': 'MSI',
|
||||
'Installer': 'apps/sample.msi',
|
||||
'InstallArgs': '/qn /norestart ALLUSERS=1',
|
||||
'_CmmVersion': '2019',
|
||||
'DetectionMethod': 'Registry',
|
||||
'DetectionPath': 'HKLM:\\SOFTWARE\\Sample',
|
||||
'DetectionName': 'DisplayVersion', 'DetectionValue': '1.2.3.4',
|
||||
'InUseCheck': {
|
||||
'Behavior': 'CloseAndReopen',
|
||||
'Processes': [
|
||||
{'Name': 'sample', 'ExePath': 'C:\\sample.exe',
|
||||
'GracefulCloseTimeoutSec': 15},
|
||||
{'Name': 'other'},
|
||||
],
|
||||
},
|
||||
},
|
||||
{
|
||||
'Name': 'Sample EXE', 'Type': 'EXE', 'Installer': 'apps/sample.exe',
|
||||
'InstallArgs': '/quiet', 'WaitTimeoutSec': 60,
|
||||
'DetectionMethod': 'FileVersion',
|
||||
'DetectionPath': 'C:\\sample.exe', 'DetectionValue': '1.0.0.0',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample PS1', 'Type': 'PS1', 'Script': 'scripts/run.ps1',
|
||||
'Args': '-Force', 'DetectionMethod': 'Always',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample File', 'Type': 'File', 'Source': 'configs/app.json',
|
||||
'Destination': 'C:\\ProgramData\\app.json',
|
||||
'DetectionMethod': 'Hash', 'DetectionPath': 'C:\\ProgramData\\app.json',
|
||||
'DetectionValue': 'a' * 64,
|
||||
'PCTypes': ['gea-shopfloor-collections', 'gea-shopfloor-cmm'],
|
||||
},
|
||||
{
|
||||
'Name': 'Sample Registry DWord', 'Type': 'Registry',
|
||||
'RegPath': 'HKLM:\\SOFTWARE\\Sample', 'RegName': 'Enabled',
|
||||
'RegValue': 1, 'RegType': 'DWord',
|
||||
'DetectionMethod': 'ValueMatches',
|
||||
'DetectionPath': 'HKLM:\\SOFTWARE\\Sample', 'DetectionName': 'Enabled',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample Registry String', 'Type': 'Registry',
|
||||
'RegPath': 'HKLM:\\SOFTWARE\\Sample', 'RegName': 'Mode',
|
||||
'RegValue': 'on', 'RegType': 'String',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample INF', 'Type': 'INF', 'Installer': 'configs/driver.inf',
|
||||
'DetectionMethod': 'pnputil', 'DetectionPattern': 'SampleDriver',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample bay-gated', 'Type': 'CMD', 'Installer': 'scripts/bay.cmd',
|
||||
'TargetMachineNumbers': ['3201', '3202'],
|
||||
},
|
||||
{
|
||||
'Name': 'Sample host-gated', 'Type': 'BAT', 'Installer': 'scripts/host.bat',
|
||||
'TargetHostnames': ['WJS-*', 'WJPC0615'],
|
||||
},
|
||||
{
|
||||
'Name': 'Sample always-installs (no detection)', 'Type': 'PS1',
|
||||
'Script': 'scripts/every.ps1',
|
||||
},
|
||||
{
|
||||
'Name': 'Sample preinstall-flagged', 'Type': 'EXE',
|
||||
'Installer': 'apps/pre.exe', 'PreEnrollment': True,
|
||||
'PCTypesStrict': True, 'KillAfterDetection': True,
|
||||
'PCTypes': ['gea-shopfloor-nocollections'],
|
||||
},
|
||||
],
|
||||
}
|
||||
|
||||
REFERENCE_SHARE = '/home/camp/pxe-images/tsgwp00525-v2/shared/dt/shopfloor'
|
||||
REFERENCE_PREINSTALL = '/home/camp/projects/pxe/playbook/preinstall/preinstall.json'
|
||||
|
||||
|
||||
def test_synthetic_manifest_round_trips_losslessly():
|
||||
"""The synthetic manifest imports + exports with full behavioral parity."""
|
||||
results, ok = run_parity([('synthetic', 'runtime', SYNTHETIC)])
|
||||
result = results[0]
|
||||
assert result['entries_identical'] == result['entries_total'], result['firstdiff']
|
||||
assert result['profiles_same'] == result['profiles_total'], result['firstdiff']
|
||||
assert ok
|
||||
|
||||
|
||||
def test_regvalue_numeric_type_preserved():
|
||||
"""A DWord RegValue of 1 round-trips as int 1, not the string '1'."""
|
||||
from plugins.geenforce.importer import build_scope
|
||||
from plugins.geenforce.serializer import scope_to_manifest
|
||||
|
||||
rebuilt = scope_to_manifest(build_scope('synthetic', 'runtime', SYNTHETIC))
|
||||
dword = next(e for e in rebuilt['Applications']
|
||||
if e['Name'] == 'Sample Registry DWord')
|
||||
assert dword['RegValue'] == 1
|
||||
assert isinstance(dword['RegValue'], int)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.path.isdir(REFERENCE_SHARE),
|
||||
reason='GE-Enforce reference share not present')
|
||||
def test_reference_share_round_trips_losslessly():
|
||||
"""Every real on-share manifest round-trips (the live Gate A)."""
|
||||
manifests = list(discover_share(REFERENCE_SHARE))
|
||||
if os.path.isfile(REFERENCE_PREINSTALL):
|
||||
manifests.append(('preinstall', 'preinstall',
|
||||
load_manifest_file(REFERENCE_PREINSTALL)))
|
||||
results, ok = run_parity(manifests)
|
||||
failed = [r for r in results if not r['passed']]
|
||||
assert ok, f"parity failures: {[(r['scopename'], r['firstdiff']) for r in failed]}"
|
||||
|
||||
|
||||
def test_fixtures_cover_every_pctype():
|
||||
"""The machine-profile fixtures include one of each imaging pctype."""
|
||||
labels = {f['pctype'] for f in load_fixtures()}
|
||||
for pctype in ('gea-shopfloor-collections', 'gea-shopfloor-nocollections',
|
||||
'gea-shopfloor-common', 'gea-shopfloor-cmm',
|
||||
'gea-shopfloor-genspect', 'gea-shopfloor-heattreat',
|
||||
'gea-shopfloor-waxtrace', 'gea-shopfloor-partmarker',
|
||||
'gea-shopfloor-display'):
|
||||
assert pctype in labels
|
||||
Reference in New Issue
Block a user