Initial import: shopdb-flask multi-site asset platform
Flask + Vue rewrite of the classic-ASP shopdb, built as a plugin framework so sister GE Aerospace sites can adopt it. Product version 0.7.0-pre-0.8.0, plugin contract 0.11.0, 966 backend tests. Key entry points: - docs/INSTALL-WINDOWS-IIS.md - tested Windows + IIS install runbook (own-site and subpath deployment methods) - docs/PILOT-DEPLOY.md - production pilot: stand up + import + verify - docs/IMPORT-API.md - schema-agnostic legacy data import contract - scripts/site_imports/wjf/ - West Jefferson reference import loader - docs/adr/ - twelve accepted architecture decision records
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']
|
||||
712
plugins/geenforce/api/routes.py
Normal file
712
plugins/geenforce/api/routes.py
Normal file
@@ -0,0 +1,712 @@
|
||||
"""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).
|
||||
"""
|
||||
|
||||
from functools import wraps
|
||||
|
||||
from flask import Blueprint, request, Response
|
||||
from flask_jwt_extended import jwt_required
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
|
||||
from shopdb.api import (
|
||||
db, success_response, error_response, ErrorCodes, require_permission,
|
||||
service_token_authorized, Setting, Application,
|
||||
)
|
||||
|
||||
SHAREROOT_SETTING = 'geenforce_share_root'
|
||||
|
||||
from ..models import (
|
||||
ManifestScope, ManifestEntry, ManifestPublishedVersion,
|
||||
ManifestEnforcementReport, ManifestPayload, ENTRY_TYPES, PHASES,
|
||||
)
|
||||
from ..serializer import scope_to_manifest, entry_to_dict
|
||||
from ..importer import build_entry, populate_entry
|
||||
from ..filters import (
|
||||
entry_applies, matches_pctype, matches_hostname, matches_machinenumber,
|
||||
matches_cmmversion,
|
||||
)
|
||||
from .. import service
|
||||
|
||||
geenforce_bp = Blueprint('geenforce', __name__)
|
||||
|
||||
FETCH_SCOPE = 'geenforce.fetch'
|
||||
REPORT_SCOPE = 'geenforce.report'
|
||||
|
||||
|
||||
def _require_service_token(scope):
|
||||
"""Decorator factory: require a managed service token scoped for `scope`.
|
||||
|
||||
Tokens are the only client auth path (the client kit provisions a
|
||||
geenforce.fetch/report token). No env-key fallback: it was never wired into
|
||||
config and an unscoped shared key is a needless backdoor.
|
||||
"""
|
||||
def wrapper(f):
|
||||
@wraps(f)
|
||||
def decorated(*args, **kwargs):
|
||||
if service_token_authorized(scope):
|
||||
return f(*args, **kwargs)
|
||||
return error_response(ErrorCodes.UNAUTHORIZED, 'Invalid API key',
|
||||
http_code=401)
|
||||
return decorated
|
||||
return wrapper
|
||||
|
||||
|
||||
require_fetch_token = _require_service_token(FETCH_SCOPE)
|
||||
require_report_token = _require_service_token(REPORT_SCOPE)
|
||||
|
||||
|
||||
# -- 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)})
|
||||
|
||||
|
||||
# -- client reporting (observed state) ----------------------------------------
|
||||
|
||||
@geenforce_bp.route('/report', methods=['POST'])
|
||||
@require_report_token
|
||||
def post_report():
|
||||
"""Record one PC's enforcement cycle: applied version (did it receive the
|
||||
update?) + per-entry self-heal outcomes (installed/skipped/failed)."""
|
||||
payload = request.get_json(silent=True) or {}
|
||||
if not (payload.get('hostname') or '').strip():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'hostname is required', http_code=400)
|
||||
try:
|
||||
report = service.record_enforcement_report(payload)
|
||||
db.session.commit()
|
||||
except ValueError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc),
|
||||
http_code=400)
|
||||
return success_response({'reportid': report.reportid,
|
||||
'status': report.status}, message='recorded')
|
||||
|
||||
|
||||
# -- 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)})
|
||||
|
||||
|
||||
@geenforce_bp.route('/applications', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def list_applications():
|
||||
"""The core Applications catalog (id + name) for the curated entry->app link
|
||||
picker in the entry editor."""
|
||||
apps = Application.query.filter_by(isactive=True).order_by(
|
||||
Application.appname).all()
|
||||
return success_response([{'appid': a.appid, 'appname': a.appname}
|
||||
for a in apps])
|
||||
|
||||
|
||||
# -- scope CRUD (geenforce.manage) --------------------------------------------
|
||||
|
||||
def _scope_summary(scope):
|
||||
current = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
return {
|
||||
'scopeid': scope.scopeid,
|
||||
'scopename': scope.scopename,
|
||||
'phase': scope.phase,
|
||||
'manifestversion': scope.manifestversion,
|
||||
'description': scope.description,
|
||||
'computertypeid': scope.computertypeid,
|
||||
'measuringtooltypeid': scope.measuringtooltypeid,
|
||||
'iscommon': scope.iscommon,
|
||||
'entrycount': len(scope.entries),
|
||||
'publishedversion': current.versionnumber if current else None,
|
||||
}
|
||||
|
||||
|
||||
def _entry_payload(entry):
|
||||
"""Manifest-entry dict with entryid + sortorder + the curated app link.
|
||||
|
||||
appid/appname are shopdb metadata (not manifest fields), added alongside the
|
||||
rendered manifest entry for the editor.
|
||||
"""
|
||||
data = {'entryid': entry.entryid, 'sortorder': entry.sortorder,
|
||||
'appid': entry.appid, 'appname': None}
|
||||
if entry.appid:
|
||||
app = db.session.get(Application, entry.appid)
|
||||
data['appname'] = app.appname if app else None
|
||||
data.update(entry_to_dict(entry))
|
||||
# Inline-payload metadata (shopdb-only, NOT manifest keys) for the editor.
|
||||
data['payloadsource'] = entry.payloadsource
|
||||
data['payloadref'] = entry.payloadref
|
||||
data['payloadsha256'] = entry.payloadsha256
|
||||
data['haspayload'] = (entry.payloadsource == 'inline'
|
||||
and entry.payloadsha256 is not None)
|
||||
return data
|
||||
|
||||
|
||||
def _apply_app_link(entry, payload):
|
||||
"""Set the optional curated appid from the payload (shopdb metadata, not a
|
||||
manifest field, so handled outside populate_entry). Ignores an unknown id."""
|
||||
if 'appid' not in payload:
|
||||
return
|
||||
appid = payload.get('appid')
|
||||
if appid in (None, '', 0):
|
||||
entry.appid = None
|
||||
return
|
||||
# Ignore a non-numeric id rather than 500 (matches the docstring contract).
|
||||
try:
|
||||
appid = int(appid)
|
||||
except (ValueError, TypeError):
|
||||
return
|
||||
if db.session.get(Application, appid):
|
||||
entry.appid = appid
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def create_scope():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
scopename = (payload.get('scopename') or '').strip()
|
||||
phase = (payload.get('phase') or 'runtime').strip()
|
||||
if not scopename:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'scopename is required', http_code=400)
|
||||
if phase not in PHASES:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
f'phase must be one of {PHASES}', http_code=400)
|
||||
if ManifestScope.query.filter_by(scopename=scopename, phase=phase).first():
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'scope already exists', http_code=400)
|
||||
scope = ManifestScope(
|
||||
scopename=scopename, phase=phase,
|
||||
manifestversion=str(payload.get('manifestversion', '1.0')),
|
||||
description=payload.get('description'),
|
||||
computertypeid=payload.get('computertypeid'),
|
||||
measuringtooltypeid=payload.get('measuringtooltypeid'),
|
||||
iscommon=bool(payload.get('iscommon', scopename == 'common')))
|
||||
db.session.add(scope)
|
||||
db.session.commit()
|
||||
return success_response(_scope_summary(scope), http_code=201)
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
data = _scope_summary(scope)
|
||||
data['entries'] = [_entry_payload(e) for e in scope.entries]
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def update_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
payload = request.get_json(silent=True) or {}
|
||||
for field in ('description', 'computertypeid', 'measuringtooltypeid'):
|
||||
if field in payload:
|
||||
setattr(scope, field, payload[field])
|
||||
if 'manifestversion' in payload:
|
||||
scope.manifestversion = str(payload['manifestversion'])
|
||||
if 'iscommon' in payload:
|
||||
scope.iscommon = bool(payload['iscommon'])
|
||||
db.session.commit()
|
||||
return success_response(_scope_summary(scope))
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def delete_scope(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
db.session.delete(scope)
|
||||
db.session.commit()
|
||||
return success_response({'deleted': scopeid})
|
||||
|
||||
|
||||
# -- entry CRUD (geenforce.manage) --------------------------------------------
|
||||
|
||||
def _validate_entry(payload):
|
||||
"""Return an error string, or None if the entry payload is valid."""
|
||||
if not (payload.get('Name') or '').strip():
|
||||
return 'entry Name is required'
|
||||
if payload.get('Type') not in ENTRY_TYPES:
|
||||
return f'entry Type must be one of {ENTRY_TYPES}'
|
||||
return None
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def create_entry(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
payload = request.get_json(silent=True) or {}
|
||||
invalid = _validate_entry(payload)
|
||||
if invalid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
||||
nextorder = max([e.sortorder for e in scope.entries], default=-1) + 1
|
||||
entry = build_entry(payload, nextorder)
|
||||
_apply_app_link(entry, payload)
|
||||
scope.entries.append(entry)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'an entry with that Name already exists in this scope',
|
||||
http_code=400)
|
||||
return success_response(_entry_payload(entry), http_code=201)
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def update_entry(entryid):
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
if not entry:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||
payload = request.get_json(silent=True) or {}
|
||||
invalid = _validate_entry(payload)
|
||||
if invalid:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, invalid, http_code=400)
|
||||
# Free the one-to-one InUseCheck (unique entryid) before populate re-inserts
|
||||
# it, so the replacement does not collide with the old row mid-flush.
|
||||
if entry.inusecheck is not None:
|
||||
db.session.delete(entry.inusecheck)
|
||||
entry.inusecheck = None
|
||||
db.session.flush()
|
||||
populate_entry(entry, payload)
|
||||
_apply_app_link(entry, payload)
|
||||
try:
|
||||
db.session.commit()
|
||||
except IntegrityError:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'an entry with that Name already exists in this scope',
|
||||
http_code=400)
|
||||
return success_response(_entry_payload(entry))
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>', methods=['DELETE'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def delete_entry(entryid):
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
if not entry:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||
db.session.delete(entry)
|
||||
db.session.commit()
|
||||
return success_response({'deleted': entryid})
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/entries/reorder', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def reorder_entries(scopeid):
|
||||
"""Set entry order from a list of entryids (the ordering contract)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
order = (request.get_json(silent=True) or {}).get('order') or []
|
||||
owned = {e.entryid: e for e in scope.entries}
|
||||
if set(order) != set(owned):
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'order must list exactly this scope\'s entry ids',
|
||||
http_code=400)
|
||||
for position, entryid in enumerate(order):
|
||||
owned[entryid].sortorder = position
|
||||
db.session.commit()
|
||||
return success_response({'order': order})
|
||||
|
||||
|
||||
# -- simulate: "what would this PC get" ---------------------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/simulate', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def simulate_scope(scopeid):
|
||||
"""Which draft entries apply to a given machine profile, and why the rest
|
||||
are filtered out (reuses the engine-mirror filters)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
profile = {
|
||||
'pctype': request.args.get('pctype') or scope.scopename,
|
||||
'subtype': request.args.get('subtype'),
|
||||
'hostname': request.args.get('hostname'),
|
||||
'machinenumber': request.args.get('machinenumber'),
|
||||
'cmmversion': request.args.get('cmmversion'),
|
||||
'phase': scope.phase,
|
||||
}
|
||||
applied, filtered = [], []
|
||||
for entry in scope.entries:
|
||||
entry_dict = entry_to_dict(entry)
|
||||
if entry_applies(entry_dict, profile):
|
||||
applied.append(entry.name)
|
||||
else:
|
||||
reasons = []
|
||||
strict_allowed = profile.get('phase') == 'preinstall'
|
||||
if not matches_pctype(entry_dict, profile['pctype'],
|
||||
profile['subtype'], strict_allowed):
|
||||
reasons.append('PCTypes')
|
||||
if not matches_hostname(entry_dict, profile['hostname']):
|
||||
reasons.append('TargetHostnames')
|
||||
if not matches_machinenumber(entry_dict, profile['machinenumber']):
|
||||
reasons.append('TargetMachineNumbers')
|
||||
if not matches_cmmversion(entry_dict, profile['cmmversion']):
|
||||
reasons.append('_CmmVersion')
|
||||
filtered.append({'name': entry.name, 'filteredby': reasons})
|
||||
return success_response({'profile': profile, 'applied': applied,
|
||||
'filtered': filtered})
|
||||
|
||||
|
||||
# -- compliance: "how much of the fleet has this app" -------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/compliance', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def scope_compliance(scopeid):
|
||||
"""Fleet-install coverage per app-linked entry (from collected PC data).
|
||||
|
||||
One row per entry that carries a curated appid; installed/version-match
|
||||
counts come from the computers plugin's ComputerInstalledApp. Degrades to
|
||||
null counts (computersplugin: false) when that plugin is absent.
|
||||
"""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope',
|
||||
http_code=404)
|
||||
return success_response(service.compliance_for_scope(scope))
|
||||
|
||||
|
||||
# -- inline payload upload / fetch (small scripts + configs) ------------------
|
||||
|
||||
PAYLOAD_MAX_BYTES = 1024 * 1024
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def upload_entry_payload(entryid):
|
||||
"""Store an inline payload (<= 1 MB) for an entry and point the entry at it."""
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
if not entry:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||
uploaded = request.files.get('file')
|
||||
if uploaded is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'a file is required', http_code=400)
|
||||
rawbytes = uploaded.read()
|
||||
if not rawbytes:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'payload is empty', http_code=400)
|
||||
if len(rawbytes) > PAYLOAD_MAX_BYTES:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'payload exceeds 1 MB limit', http_code=400)
|
||||
service.store_inline_payload(entry, uploaded.filename,
|
||||
uploaded.mimetype, rawbytes)
|
||||
db.session.commit()
|
||||
return success_response(_entry_payload(entry), http_code=201)
|
||||
|
||||
|
||||
@geenforce_bp.route('/entries/<int:entryid>/payload', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def download_entry_payload(entryid):
|
||||
"""Return the stored inline payload bytes for an entry (404 if none)."""
|
||||
entry = db.session.get(ManifestEntry, entryid)
|
||||
if not entry:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such entry', http_code=404)
|
||||
payload = ManifestPayload.query.filter_by(entryid=entryid).first()
|
||||
if not payload:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'entry has no payload',
|
||||
http_code=404)
|
||||
return Response(
|
||||
payload.payloadbytes,
|
||||
mimetype=payload.contenttype or 'application/octet-stream',
|
||||
headers={'Content-Disposition':
|
||||
f'attachment; filename="{payload.filename}"'})
|
||||
|
||||
|
||||
# -- publish lifecycle (geenforce.publish) ------------------------------------
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/publish', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def publish_scope_route(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
from flask_jwt_extended import get_jwt_identity
|
||||
notes = (request.get_json(silent=True) or {}).get('notes')
|
||||
try:
|
||||
publishedby = int(get_jwt_identity())
|
||||
except (TypeError, ValueError):
|
||||
publishedby = None
|
||||
version = service.publish_scope(scope.scopename, scope.phase,
|
||||
notes=notes, publishedby=publishedby)
|
||||
db.session.commit()
|
||||
return success_response({'versionnumber': version}, http_code=201)
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def list_versions(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
versions = ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scopeid).order_by(
|
||||
ManifestPublishedVersion.versionnumber.desc()).all()
|
||||
return success_response([{
|
||||
'versionnumber': v.versionnumber,
|
||||
'iscurrent': v.iscurrent,
|
||||
'publishedat': v.publishedat.isoformat() + 'Z' if v.publishedat else None,
|
||||
'publishedby': v.publishedby,
|
||||
'notes': v.notes,
|
||||
} for v in versions])
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/versions/<int:versionnumber>',
|
||||
methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_version(scopeid, versionnumber):
|
||||
version = ManifestPublishedVersion.query.filter_by(
|
||||
scopeid=scopeid, versionnumber=versionnumber).first()
|
||||
if not version:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such version',
|
||||
http_code=404)
|
||||
import json as _json
|
||||
return success_response({'versionnumber': version.versionnumber,
|
||||
'manifest': _json.loads(version.manifestjson)})
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_config():
|
||||
"""Plugin config: the on-share export root (for export-to-share)."""
|
||||
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||
return success_response({'shareroot': setting.value if setting else ''})
|
||||
|
||||
|
||||
@geenforce_bp.route('/config', methods=['PUT'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def put_config():
|
||||
payload = request.get_json(silent=True) or {}
|
||||
Setting.set(SHAREROOT_SETTING, (payload.get('shareroot') or '').strip(),
|
||||
valuetype='string', category='geenforce',
|
||||
description='On-share export root for GE-Enforce manifests')
|
||||
db.session.commit()
|
||||
return success_response({'shareroot': (payload.get('shareroot') or '').strip()})
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/export-share', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def export_share_route(scopeid):
|
||||
"""Write the scope's current published JSON to the configured share root
|
||||
(backing up the old file to _meta/history first)."""
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
setting = Setting.query.filter_by(key=SHAREROOT_SETTING).first()
|
||||
shareroot = setting.value if setting else ''
|
||||
if not shareroot:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'Configure the share root first', http_code=400)
|
||||
try:
|
||||
path = service.export_scope_to_share(scope.scopename, scope.phase,
|
||||
shareroot)
|
||||
except (ValueError, OSError) as exc:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
||||
return success_response({'path': path})
|
||||
|
||||
|
||||
@geenforce_bp.route('/scopes/<int:scopeid>/rollback', methods=['POST'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.publish')
|
||||
def rollback_scope_route(scopeid):
|
||||
scope = db.session.get(ManifestScope, scopeid)
|
||||
if not scope:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such scope', http_code=404)
|
||||
versionnumber = (request.get_json(silent=True) or {}).get('versionnumber')
|
||||
if versionnumber is None:
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR,
|
||||
'versionnumber is required', http_code=400)
|
||||
try:
|
||||
service.rollback_scope(scope.scopename, scope.phase, int(versionnumber))
|
||||
db.session.commit()
|
||||
except ValueError as exc:
|
||||
db.session.rollback()
|
||||
return error_response(ErrorCodes.VALIDATION_ERROR, str(exc), http_code=400)
|
||||
return success_response({'versionnumber': int(versionnumber)})
|
||||
|
||||
|
||||
def _current_published_version(scopename, phase):
|
||||
scope = ManifestScope.query.filter_by(
|
||||
scopename=scopename, phase=phase).first()
|
||||
if not scope:
|
||||
return None
|
||||
published = scope.publishedversions.filter_by(iscurrent=True).first()
|
||||
return published.versionnumber if published else None
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def list_reports():
|
||||
"""Latest enforcement report per PC (fleet compliance view).
|
||||
|
||||
Each row shows the applied vs latest published version (receivedlatest = the
|
||||
PC picked up the update) and the install/skip/fail counts + status.
|
||||
Optional filters: hostname, scopename.
|
||||
"""
|
||||
query = ManifestEnforcementReport.query.filter_by(iscurrent=True)
|
||||
hostname = request.args.get('hostname')
|
||||
scopename = request.args.get('scopename')
|
||||
if hostname:
|
||||
query = query.filter(ManifestEnforcementReport.hostname.ilike(hostname))
|
||||
if scopename:
|
||||
query = query.filter_by(scopename=scopename)
|
||||
reports = query.order_by(
|
||||
ManifestEnforcementReport.receivedat.desc()).all()
|
||||
|
||||
latest_cache = {}
|
||||
data = []
|
||||
for report in reports:
|
||||
key = (report.scopename, report.phase)
|
||||
if key not in latest_cache:
|
||||
latest_cache[key] = _current_published_version(*key)
|
||||
latest = latest_cache[key]
|
||||
data.append({
|
||||
'reportid': report.reportid,
|
||||
'hostname': report.hostname,
|
||||
'scopename': report.scopename,
|
||||
'phase': report.phase,
|
||||
'appliedversion': report.appliedversion,
|
||||
'latestversion': latest,
|
||||
'receivedlatest': (latest is not None
|
||||
and report.appliedversion == latest),
|
||||
'enforcerversion': report.enforcerversion,
|
||||
'status': report.status,
|
||||
'installed': report.installedcount,
|
||||
'skipped': report.skippedcount,
|
||||
'failed': report.failedcount,
|
||||
'filtered': report.filteredcount,
|
||||
'lastcheckin': (report.lastcheckin.isoformat() + 'Z'
|
||||
if report.lastcheckin else None),
|
||||
'receivedat': (report.receivedat.isoformat() + 'Z'
|
||||
if report.receivedat else None),
|
||||
})
|
||||
return success_response(data)
|
||||
|
||||
|
||||
@geenforce_bp.route('/reports/<int:reportid>', methods=['GET'])
|
||||
@jwt_required()
|
||||
@require_permission('geenforce.manage')
|
||||
def get_report(reportid):
|
||||
"""One report with per-entry outcomes (self-heal / failure detail)."""
|
||||
report = db.session.get(ManifestEnforcementReport, reportid)
|
||||
if not report:
|
||||
return error_response(ErrorCodes.NOT_FOUND, 'No such report',
|
||||
http_code=404)
|
||||
latest = _current_published_version(report.scopename, report.phase)
|
||||
return success_response({
|
||||
'reportid': report.reportid,
|
||||
'hostname': report.hostname,
|
||||
'scopename': report.scopename,
|
||||
'phase': report.phase,
|
||||
'appliedversion': report.appliedversion,
|
||||
'latestversion': latest,
|
||||
'receivedlatest': (latest is not None
|
||||
and report.appliedversion == latest),
|
||||
'status': report.status,
|
||||
'results': [{
|
||||
'entryname': r.entryname,
|
||||
'action': r.action,
|
||||
'selfhealed': r.selfhealed,
|
||||
'exitcode': r.exitcode,
|
||||
'message': r.message,
|
||||
} for r in report.results],
|
||||
})
|
||||
180
plugins/geenforce/client/Install-GEEnforce.ps1
Normal file
180
plugins/geenforce/client/Install-GEEnforce.ps1
Normal file
@@ -0,0 +1,180 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Provision a PC for GE-Enforce + shopdb: write its identity (so it knows its PC
|
||||
type and bay), deploy the shopdb client kit, and register the enforcement
|
||||
scheduled task. Site-neutral and imaging-path independent - run it from a PXE
|
||||
step, an OOBE provisioning package (ppkg), Intune, or by hand.
|
||||
|
||||
This is the "set up the PC to determine its PC type and such" step. There is no
|
||||
auto-detection: the caller supplies the type (and, where relevant, the bay
|
||||
machine number and CMM version), and this script writes the files/registry the
|
||||
GE-Enforce engine reads.
|
||||
|
||||
This script does NOT contain the GE-Enforce engine (Install-FromManifest.ps1)
|
||||
or dispatcher - those are the GE-Enforce framework's. Point at your copy with
|
||||
-EngineSource, or ensure it is already present under -InstallRoot\lib.
|
||||
|
||||
.PARAMETER PCType
|
||||
The imaging PC type = the manifest scope this PC runs. One of the scope names
|
||||
(e.g. gea-shopfloor-cmm, gea-shopfloor-collections) or a legacy alias the
|
||||
engine maps. Written to C:\Enrollment\pc-type.txt. REQUIRED.
|
||||
|
||||
.PARAMETER MachineNumber
|
||||
The bay machine number (for TargetMachineNumbers gates). Written to
|
||||
machine-number.txt as a fallback; the DNC registry MachineNo wins if present.
|
||||
|
||||
.PARAMETER CmmVersion
|
||||
CMM bays only: the resolved PC-DMIS version (2016/2019/2026) for _CmmVersion
|
||||
gating. Written to C:\Enrollment\cmm\version.txt. Omit for non-CMM PCs.
|
||||
|
||||
.PARAMETER CmmId
|
||||
CMM bays only: the bay id. Written to C:\Enrollment\cmm\cmmid.txt.
|
||||
|
||||
.PARAMETER ShareRoot
|
||||
The GE-Enforce share root (UNC), written into site-config.json as
|
||||
shopfloorShareRoot.
|
||||
|
||||
.PARAMETER Site
|
||||
Site name, written into site-config.json.
|
||||
|
||||
.PARAMETER ShopdbUrl
|
||||
shopdb base URL (scheme + host) for the fetch/report client. Written to
|
||||
HKLM:\SOFTWARE\GE\ShopDB\BaseUrl.
|
||||
|
||||
.PARAMETER ShopdbToken
|
||||
A geenforce.fetch (+ geenforce.report) managed service token. Written to
|
||||
HKLM:\SOFTWARE\GE\ShopDB\ApiToken. (In many sites Azure DSC provisions this
|
||||
instead - omit here if so.)
|
||||
|
||||
.PARAMETER EngineSource
|
||||
Optional path (folder or share) containing the GE-Enforce engine to copy in:
|
||||
expects GE-Enforce.ps1 and lib\Install-FromManifest.ps1. If omitted, the engine
|
||||
is assumed already present under -InstallRoot.
|
||||
|
||||
.PARAMETER InstallRoot
|
||||
Where the client kit + engine live on the PC. Default C:\ProgramData\GE-Enforce.
|
||||
|
||||
.PARAMETER TaskName
|
||||
Scheduled task name. Default 'GE-Enforce'.
|
||||
|
||||
.PARAMETER IntervalMinutes
|
||||
How often the enforcement task repeats. Default 5.
|
||||
|
||||
.PARAMETER NoTask
|
||||
Provision identity + kit only; do not register the scheduled task.
|
||||
|
||||
.EXAMPLE
|
||||
.\Install-GEEnforce.ps1 -PCType gea-shopfloor-cmm -MachineNumber 0615 `
|
||||
-CmmVersion 2019 -ShareRoot \\server\share\dt\shopfloor -Site "West Jefferson" `
|
||||
-ShopdbUrl https://shopdb.site.geaerospace.net -ShopdbToken shopdb_pat_xxx `
|
||||
-EngineSource \\server\share\dt\shopfloor\common
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$PCType,
|
||||
[string]$MachineNumber,
|
||||
[string]$CmmVersion,
|
||||
[string]$CmmId,
|
||||
[string]$ShareRoot,
|
||||
[string]$Site,
|
||||
[string]$ShopdbUrl,
|
||||
[string]$ShopdbToken,
|
||||
[string]$EngineSource,
|
||||
[string]$InstallRoot = 'C:\ProgramData\GE-Enforce',
|
||||
[string]$EnrollmentRoot = 'C:\Enrollment',
|
||||
[string]$TaskName = 'GE-Enforce',
|
||||
[int]$IntervalMinutes = 5,
|
||||
[switch]$NoTask
|
||||
)
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
function Write-TextFile {
|
||||
param([string]$Path, [string]$Value)
|
||||
$dir = Split-Path -Parent $Path
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
Set-Content -LiteralPath $Path -Value $Value -Encoding ascii -NoNewline
|
||||
Write-Host " wrote $Path"
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "GE-Enforce provisioning: PCType=$PCType"
|
||||
|
||||
# --- 1. Identity: what the PC is (what the engine reads) -----------------
|
||||
Write-TextFile (Join-Path $EnrollmentRoot 'pc-type.txt') $PCType
|
||||
if ($MachineNumber) { Write-TextFile (Join-Path $EnrollmentRoot 'machine-number.txt') $MachineNumber }
|
||||
if ($CmmVersion) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\version.txt') $CmmVersion }
|
||||
if ($CmmId) { Write-TextFile (Join-Path $EnrollmentRoot 'cmm\cmmid.txt') $CmmId }
|
||||
|
||||
$siteConfig = @{}
|
||||
if ($ShareRoot) { $siteConfig['shopfloorShareRoot'] = $ShareRoot }
|
||||
if ($Site) { $siteConfig['site'] = $Site }
|
||||
if ($siteConfig.Count) {
|
||||
$siteConfigPath = Join-Path $EnrollmentRoot 'site-config.json'
|
||||
$dir = Split-Path -Parent $siteConfigPath
|
||||
if (-not (Test-Path $dir)) { New-Item -ItemType Directory -Path $dir -Force | Out-Null }
|
||||
($siteConfig | ConvertTo-Json) | Set-Content -LiteralPath $siteConfigPath -Encoding ascii
|
||||
Write-Host " wrote $siteConfigPath"
|
||||
}
|
||||
|
||||
# --- 2. shopdb client registry config ------------------------------------
|
||||
if ($ShopdbUrl -or $ShopdbToken) {
|
||||
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
if (-not (Test-Path $regPath)) { New-Item -Path $regPath -Force | Out-Null }
|
||||
if ($ShopdbUrl) { Set-ItemProperty -Path $regPath -Name BaseUrl -Value $ShopdbUrl }
|
||||
if ($ShopdbToken) { Set-ItemProperty -Path $regPath -Name ApiToken -Value $ShopdbToken }
|
||||
Write-Host " wrote HKLM:\SOFTWARE\GE\ShopDB"
|
||||
}
|
||||
|
||||
# --- 3. Deploy the client kit (ships alongside this script) --------------
|
||||
if (-not (Test-Path $InstallRoot)) { New-Item -ItemType Directory -Path $InstallRoot -Force | Out-Null }
|
||||
foreach ($file in 'ShopdbEnforceClient.psm1', 'Invoke-ShopdbEnforce.ps1') {
|
||||
$src = Join-Path $PSScriptRoot $file
|
||||
if (Test-Path $src) {
|
||||
Copy-Item -Path $src -Destination (Join-Path $InstallRoot $file) -Force
|
||||
Write-Host " deployed $file"
|
||||
} else {
|
||||
Write-Warning "client kit file not found next to this script: $file"
|
||||
}
|
||||
}
|
||||
|
||||
# --- 4. Engine (referenced, not vendored) --------------------------------
|
||||
if ($EngineSource) {
|
||||
Copy-Item -Path (Join-Path $EngineSource 'GE-Enforce.ps1') `
|
||||
-Destination (Join-Path $InstallRoot 'GE-Enforce.ps1') -Force -ErrorAction SilentlyContinue
|
||||
$libDir = Join-Path $InstallRoot 'lib'
|
||||
if (-not (Test-Path $libDir)) { New-Item -ItemType Directory -Path $libDir -Force | Out-Null }
|
||||
Copy-Item -Path (Join-Path $EngineSource 'lib\Install-FromManifest.ps1') `
|
||||
-Destination (Join-Path $libDir 'Install-FromManifest.ps1') -Force -ErrorAction SilentlyContinue
|
||||
Write-Host " copied engine from $EngineSource"
|
||||
}
|
||||
$enginePath = Join-Path $InstallRoot 'lib\Install-FromManifest.ps1'
|
||||
if (-not (Test-Path $enginePath)) {
|
||||
Write-Warning "engine not present at $enginePath - provide -EngineSource or place it there before enforcement runs."
|
||||
}
|
||||
|
||||
# --- 5. Scheduled task: run the enforcement client as SYSTEM -------------
|
||||
if (-not $NoTask) {
|
||||
$runner = Join-Path $InstallRoot 'Invoke-ShopdbEnforce.ps1'
|
||||
$arguments = "-NoProfile -ExecutionPolicy Bypass -File `"$runner`" -Scope `"$PCType`" -EnginePath `"$enginePath`""
|
||||
$action = New-ScheduledTaskAction -Execute 'powershell.exe' -Argument $arguments
|
||||
$triggers = @(
|
||||
(New-ScheduledTaskTrigger -AtLogOn),
|
||||
(New-ScheduledTaskTrigger -Once -At (Get-Date) `
|
||||
-RepetitionInterval (New-TimeSpan -Minutes $IntervalMinutes))
|
||||
)
|
||||
$principal = New-ScheduledTaskPrincipal -UserId 'NT AUTHORITY\SYSTEM' -RunLevel Highest
|
||||
$settings = New-ScheduledTaskSettingsSet -AllowStartIfOnBatteries `
|
||||
-DontStopIfGoingOnBatteries -StartWhenAvailable
|
||||
Register-ScheduledTask -TaskName $TaskName -Action $action -Trigger $triggers `
|
||||
-Principal $principal -Settings $settings -Force | Out-Null
|
||||
Write-Host " registered task '$TaskName' (at logon + every $IntervalMinutes min)"
|
||||
}
|
||||
|
||||
Write-Host "GE-Enforce provisioning complete."
|
||||
exit 0
|
||||
} catch {
|
||||
Write-Error "GE-Enforce provisioning FAILED: $($_.Exception.Message)"
|
||||
exit 1
|
||||
}
|
||||
103
plugins/geenforce/client/Invoke-ShopdbEnforce.ps1
Normal file
103
plugins/geenforce/client/Invoke-ShopdbEnforce.ps1
Normal file
@@ -0,0 +1,103 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Reference orchestrator: source a GE-Enforce manifest from shopdb, run the
|
||||
UNCHANGED engine against it, and report the result back to shopdb.
|
||||
|
||||
This is a thin wrapper around Install-FromManifest.ps1 (the engine). It is a
|
||||
reference a site adapts into its GE-Enforce.ps1 flow; it is not the live
|
||||
dispatcher. The engine, detection, self-heal, and SMB payload resolution are
|
||||
untouched - only the source of the manifest JSON moves from a share file to
|
||||
shopdb, plus a result report.
|
||||
|
||||
.PARAMETER Scope
|
||||
The imaging pc-type / scope name (e.g. gea-shopfloor-cmm), same value the
|
||||
dispatcher already resolves from C:\Enrollment\pc-type.txt.
|
||||
|
||||
.PARAMETER EnginePath
|
||||
Path to Install-FromManifest.ps1 (the engine lib, >= 2.6).
|
||||
|
||||
.PARAMETER ShareManifestPath
|
||||
The current on-share manifest for this scope. In shadow mode the engine runs
|
||||
against THIS (unchanged behavior) and shopdb is only compared + reported. Once
|
||||
cut over, omit it and the engine runs against the shopdb-sourced manifest.
|
||||
|
||||
.PARAMETER ShadowMode
|
||||
Fetch + compare + report, but install from the share (no behavior change).
|
||||
|
||||
.NOTES
|
||||
Fail-safe: any error exits 0 so a bad web app never blocks or breaks a PC.
|
||||
Config comes from HKLM:\SOFTWARE\GE\ShopDB (BaseUrl, ApiToken) - see the psm1.
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Scope,
|
||||
[Parameter(Mandatory)] [string]$EnginePath,
|
||||
[string]$ShareManifestPath,
|
||||
[switch]$ShadowMode,
|
||||
[string]$BaseUrl,
|
||||
[string]$ApiToken,
|
||||
[string]$LogFile = "C:\Logs\Shopfloor\shopdb-enforce-$(Get-Date -Format yyyyMMdd).log"
|
||||
)
|
||||
|
||||
function Write-Log {
|
||||
param([string]$Message, [string]$Level = 'INFO')
|
||||
$line = "$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss') [$Level] $Message"
|
||||
try { Add-Content -LiteralPath $LogFile -Value $line -ErrorAction SilentlyContinue } catch {}
|
||||
Write-Host $line
|
||||
}
|
||||
|
||||
try {
|
||||
Import-Module (Join-Path $PSScriptRoot 'ShopdbEnforceClient.psm1') -Force
|
||||
|
||||
$config = Get-ShopdbConfig -BaseUrl $BaseUrl -ApiToken $ApiToken
|
||||
if (-not $config) {
|
||||
Write-Log 'No shopdb BaseUrl/ApiToken configured yet - retry next cycle.' 'WARN'
|
||||
exit 0
|
||||
}
|
||||
|
||||
$sync = Sync-ShopdbManifest -Scope $Scope -Config $config
|
||||
if (-not $sync.Path) {
|
||||
Write-Log "No manifest available for $Scope (shopdb unreachable, no cache)." 'WARN'
|
||||
exit 0
|
||||
}
|
||||
Write-Log "Manifest for $Scope from $($sync.Source) (v$($sync.Version))."
|
||||
|
||||
# Shadow mode: compare shopdb vs the share, but install from the share.
|
||||
if ($ShadowMode -and $ShareManifestPath -and (Test-Path $ShareManifestPath)) {
|
||||
$diff = Compare-ShopdbShadow -ShopdbManifestPath $sync.Path -ShareManifestPath $ShareManifestPath
|
||||
if ($diff.Same) {
|
||||
Write-Log 'Shadow: shopdb manifest matches the share.'
|
||||
} else {
|
||||
Write-Log ("Shadow DIFF: shopdb-only=[{0}] share-only=[{1}] orderDiff={2}" -f `
|
||||
($diff.ShopdbOnly -join ','), ($diff.ShareOnly -join ','), $diff.OrderDiff) 'WARN'
|
||||
}
|
||||
}
|
||||
|
||||
# Which manifest the engine actually runs against.
|
||||
$manifestToRun = if ($ShadowMode -and $ShareManifestPath) { $ShareManifestPath } else { $sync.Path }
|
||||
|
||||
# --- INTEGRATION POINT ---------------------------------------------------
|
||||
# Run the engine. Install-FromManifest.ps1 is expected to return (or you
|
||||
# adapt it to return) a summary carrying Installed/Skipped/Failed/Filtered
|
||||
# and a Results list of @{Name;Action;SelfHealed;ExitCode;Message}. Wire this
|
||||
# to your engine's actual return/parse; the shape below is the contract.
|
||||
Write-Log "Running engine against $manifestToRun"
|
||||
$summary = & $EnginePath -ManifestPath $manifestToRun -PCType $Scope
|
||||
if (-not $summary) {
|
||||
$summary = @{ Installed = 0; Skipped = 0; Failed = 0; Filtered = 0; Results = @() }
|
||||
}
|
||||
if (-not $summary.EnforcerVersion) { $summary.EnforcerVersion = '2.6' }
|
||||
|
||||
# Report the result (best-effort).
|
||||
$appliedVersion = 0
|
||||
if ($sync.Version) { [int]::TryParse($sync.Version, [ref]$appliedVersion) | Out-Null }
|
||||
$report = New-ShopdbReport -Scope $Scope -AppliedVersion $appliedVersion -Summary $summary
|
||||
if (Send-ShopdbReport -Config $config -Report $report) {
|
||||
Write-Log "Reported: installed=$($summary.Installed) skipped=$($summary.Skipped) failed=$($summary.Failed)."
|
||||
} else {
|
||||
Write-Log 'Report POST failed (non-fatal).' 'WARN'
|
||||
}
|
||||
} catch {
|
||||
Write-Log "Unhandled error (non-fatal): $($_.Exception.Message)" 'ERROR'
|
||||
}
|
||||
exit 0
|
||||
164
plugins/geenforce/client/ShopdbEnforceClient.psm1
Normal file
164
plugins/geenforce/client/ShopdbEnforceClient.psm1
Normal file
@@ -0,0 +1,164 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Client-side helpers for sourcing GE-Enforce manifests from shopdb and reporting
|
||||
results back. Site-neutral reference kit - deploy alongside GE-Enforce; do NOT
|
||||
hardcode any site here.
|
||||
|
||||
This module does NOT replace Install-FromManifest.ps1 (the engine). It only
|
||||
changes where the manifest JSON comes from (shopdb HTTP instead of a share
|
||||
file) and adds a result report. The engine, detection, self-heal, and SMB
|
||||
payload resolution stay exactly as they are.
|
||||
|
||||
Resilience mirrors GE-Enforce: any failure is non-fatal (fail-safe). If shopdb
|
||||
is unreachable the client enforces from the last-known-good cached manifest and
|
||||
a PC is never left unmanaged because the web app is down.
|
||||
|
||||
Config (params override registry): HKLM:\SOFTWARE\GE\ShopDB
|
||||
BaseUrl e.g. https://shopdb.example.geaerospace.net
|
||||
ApiToken a geenforce.fetch (+ geenforce.report) managed service token,
|
||||
provisioned the same way as SFLD creds (Azure DSC).
|
||||
#>
|
||||
|
||||
Set-StrictMode -Version Latest
|
||||
|
||||
function Get-ShopdbConfig {
|
||||
param([string]$BaseUrl, [string]$ApiToken)
|
||||
$regPath = 'HKLM:\SOFTWARE\GE\ShopDB'
|
||||
if ((-not $BaseUrl -or -not $ApiToken) -and (Test-Path $regPath)) {
|
||||
$props = Get-ItemProperty -Path $regPath -ErrorAction SilentlyContinue
|
||||
if (-not $BaseUrl -and $props.BaseUrl) { $BaseUrl = $props.BaseUrl }
|
||||
if (-not $ApiToken -and $props.ApiToken) { $ApiToken = $props.ApiToken }
|
||||
}
|
||||
if (-not $BaseUrl -or -not $ApiToken) { return $null }
|
||||
return @{ BaseUrl = $BaseUrl.TrimEnd('/'); ApiToken = $ApiToken }
|
||||
}
|
||||
|
||||
function Sync-ShopdbManifest {
|
||||
<#
|
||||
Fetch the current published manifest for a scope into a local cache, using
|
||||
an ETag so an unchanged manifest is a cheap 304. On any network error, fall
|
||||
back to the last-known-good cached copy. Returns:
|
||||
@{ Path; Version; Source } where Source is
|
||||
'shopdb' | 'cache-304' | 'cache-lastgood' | $null (nothing available)
|
||||
#>
|
||||
param(
|
||||
[Parameter(Mandatory)] [string]$Scope,
|
||||
[Parameter(Mandatory)] [hashtable]$Config,
|
||||
[string]$CacheDir = 'C:\ProgramData\ShopDB\geenforce'
|
||||
)
|
||||
if (-not (Test-Path $CacheDir)) { New-Item -ItemType Directory -Path $CacheDir -Force | Out-Null }
|
||||
$manifestPath = Join-Path $CacheDir "$Scope.json"
|
||||
$etagPath = Join-Path $CacheDir "$Scope.etag"
|
||||
$headers = @{ 'X-API-Key' = $Config.ApiToken }
|
||||
if (Test-Path $etagPath) { $headers['If-None-Match'] = (Get-Content -LiteralPath $etagPath -Raw).Trim() }
|
||||
|
||||
$uri = "$($Config.BaseUrl)/api/geenforce/manifest?pctype=$([uri]::EscapeDataString($Scope))"
|
||||
try {
|
||||
$response = Invoke-WebRequest -Uri $uri -Headers $headers -UseBasicParsing `
|
||||
-TimeoutSec 30 -ErrorAction Stop
|
||||
if ($response.StatusCode -eq 200) {
|
||||
[System.IO.File]::WriteAllText($manifestPath, $response.Content)
|
||||
# PowerShell 7 returns header values as string arrays; 5.1 as scalars.
|
||||
# @(...)[0] yields a clean scalar in both.
|
||||
$etag = @($response.Headers['ETag'])[0]
|
||||
if ($etag) {
|
||||
Set-Content -LiteralPath $etagPath -Value $etag -NoNewline
|
||||
}
|
||||
$version = @($response.Headers['X-Manifest-Version'])[0]
|
||||
if ($version) {
|
||||
Set-Content -LiteralPath (Join-Path $CacheDir "$Scope.version") -Value $version -NoNewline
|
||||
}
|
||||
return @{ Path = $manifestPath; Version = $version; Source = 'shopdb' }
|
||||
}
|
||||
} catch {
|
||||
$status = $null
|
||||
if ($_.Exception.Response) { $status = [int]$_.Exception.Response.StatusCode }
|
||||
if ($status -eq 304 -and (Test-Path $manifestPath)) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
|
||||
}
|
||||
# Network/other failure: fall back to last-known-good.
|
||||
if (Test-Path $manifestPath) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-lastgood' }
|
||||
}
|
||||
return @{ Path = $null; Version = $null; Source = $null }
|
||||
}
|
||||
# 304 without exception (some PS versions) -> use cache.
|
||||
if (Test-Path $manifestPath) {
|
||||
return @{ Path = $manifestPath; Version = (Read-CachedVersion $CacheDir $Scope); Source = 'cache-304' }
|
||||
}
|
||||
return @{ Path = $null; Version = $null; Source = $null }
|
||||
}
|
||||
|
||||
function Read-CachedVersion {
|
||||
param([string]$CacheDir, [string]$Scope)
|
||||
$verPath = Join-Path $CacheDir "$Scope.version"
|
||||
if (Test-Path $verPath) { return (Get-Content -LiteralPath $verPath -Raw).Trim() }
|
||||
return $null
|
||||
}
|
||||
|
||||
function Compare-ShopdbShadow {
|
||||
<#
|
||||
Shadow-mode comparison: do the shopdb manifest and the on-share manifest
|
||||
select the same ordered entry names? Returns @{ Same; ShopdbOnly; ShareOnly;
|
||||
OrderDiff }. Behavioral, not byte, comparison.
|
||||
#>
|
||||
param([Parameter(Mandatory)][string]$ShopdbManifestPath,
|
||||
[Parameter(Mandatory)][string]$ShareManifestPath)
|
||||
$shopdb = (Get-Content -LiteralPath $ShopdbManifestPath -Raw | ConvertFrom-Json)
|
||||
$share = (Get-Content -LiteralPath $ShareManifestPath -Raw | ConvertFrom-Json)
|
||||
$shopdbNames = @($shopdb.Applications | ForEach-Object { $_.Name })
|
||||
$shareNames = @($share.Applications | ForEach-Object { $_.Name })
|
||||
return @{
|
||||
Same = (($shopdbNames -join '|') -eq ($shareNames -join '|'))
|
||||
ShopdbOnly = @($shopdbNames | Where-Object { $_ -notin $shareNames })
|
||||
ShareOnly = @($shareNames | Where-Object { $_ -notin $shopdbNames })
|
||||
OrderDiff = (($shopdbNames -join '|') -ne ($shareNames -join '|'))
|
||||
}
|
||||
}
|
||||
|
||||
function Send-ShopdbReport {
|
||||
<#
|
||||
POST an enforcement report to shopdb. Best-effort: never throws, returns
|
||||
$true on success. Report is a hashtable matching POST /api/geenforce/report.
|
||||
#>
|
||||
param([Parameter(Mandatory)][hashtable]$Config,
|
||||
[Parameter(Mandatory)][hashtable]$Report)
|
||||
try {
|
||||
$body = ($Report | ConvertTo-Json -Depth 6)
|
||||
Invoke-RestMethod -Uri "$($Config.BaseUrl)/api/geenforce/report" `
|
||||
-Method Post -Headers @{ 'X-API-Key' = $Config.ApiToken } `
|
||||
-ContentType 'application/json' -Body $body -TimeoutSec 30 -ErrorAction Stop | Out-Null
|
||||
return $true
|
||||
} catch {
|
||||
return $false
|
||||
}
|
||||
}
|
||||
|
||||
function New-ShopdbReport {
|
||||
<#
|
||||
Build a report payload from an engine summary. `Summary` is expected to
|
||||
carry Installed/Skipped/Failed/Filtered counts and a Results list of
|
||||
@{ Name; Action; SelfHealed; ExitCode; Message }. Shape the engine's own
|
||||
per-entry outcomes into this at the call site.
|
||||
#>
|
||||
param([string]$Hostname = $env:COMPUTERNAME,
|
||||
[Parameter(Mandatory)][string]$Scope,
|
||||
[int]$AppliedVersion,
|
||||
[Parameter(Mandatory)][hashtable]$Summary)
|
||||
return @{
|
||||
hostname = $Hostname
|
||||
scopename = $Scope
|
||||
appliedversion = $AppliedVersion
|
||||
enforcerversion = $Summary.EnforcerVersion
|
||||
counts = @{
|
||||
installed = [int]$Summary.Installed
|
||||
skipped = [int]$Summary.Skipped
|
||||
failed = [int]$Summary.Failed
|
||||
filtered = [int]$Summary.Filtered
|
||||
}
|
||||
results = @($Summary.Results)
|
||||
}
|
||||
}
|
||||
|
||||
Export-ModuleMember -Function Get-ShopdbConfig, Sync-ShopdbManifest, `
|
||||
Compare-ShopdbShadow, Send-ShopdbReport, New-ShopdbReport, Read-CachedVersion
|
||||
144
plugins/geenforce/filters.py
Normal file
144
plugins/geenforce/filters.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""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, strict_allowed=False):
|
||||
"""Test-PCTypeMatches: no PCTypes = all; '*' = all; alias-set intersection.
|
||||
|
||||
PCTypesStrict=true disables alias expansion (both the PC's identity and the
|
||||
manifest values are compared literally), so a 'collections'-only strict entry
|
||||
does NOT match a 'nocollections' PC via their shared 'Standard' alias group.
|
||||
IMPORTANT: only the PREINSTALL runner honors this flag - the runtime engine
|
||||
(Install-FromManifest.ps1's Test-PCTypeMatches) has no strict handling. So
|
||||
strict is applied only when strict_allowed (i.e. phase == 'preinstall'),
|
||||
else a runtime entry with PCTypesStrict still alias-expands, matching the
|
||||
real engine.
|
||||
"""
|
||||
values = entry.get('PCTypes') or []
|
||||
if not values:
|
||||
return True
|
||||
if not pctype:
|
||||
return True
|
||||
strict = strict_allowed and bool(entry.get('PCTypesStrict'))
|
||||
# Names the current PC matches: bare type, "type-subtype", and (unless strict)
|
||||
# all aliases of either.
|
||||
mynames = set()
|
||||
mynames.add(pctype.lower())
|
||||
seeds = [pctype]
|
||||
if subtype:
|
||||
combined = f'{pctype}-{subtype}'
|
||||
mynames.add(combined.lower())
|
||||
seeds.append(combined)
|
||||
if not strict:
|
||||
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
|
||||
if strict:
|
||||
continue
|
||||
# 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, and
|
||||
optional phase ('preinstall' enables PCTypesStrict, matching the runners).
|
||||
"""
|
||||
strict_allowed = profile.get('phase') == 'preinstall'
|
||||
return (matches_pctype(entry, profile.get('pctype'), profile.get('subtype'),
|
||||
strict_allowed)
|
||||
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)]
|
||||
117
plugins/geenforce/importer.py
Normal file
117
plugins/geenforce/importer.py
Normal file
@@ -0,0 +1,117 @@
|
||||
"""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}
|
||||
|
||||
|
||||
# Scalar model attributes that a populate resets (so an update clears fields the
|
||||
# new payload omits). name/entrytype are required and set explicitly.
|
||||
_RESETTABLE_ATTRS = [attr for _, attr in _SCALAR_FIELDS
|
||||
if attr not in ('name', 'entrytype')] + ['comment', 'regvalue']
|
||||
|
||||
|
||||
def populate_entry(entry, entry_dict):
|
||||
"""Set every field + child on an existing (or new) ManifestEntry from one
|
||||
Applications[] entry dict. Existing children are replaced. Returns entry."""
|
||||
entry.name = entry_dict.get('Name')
|
||||
entry.entrytype = entry_dict.get('Type')
|
||||
entry.comment = entry_dict.get('_comment')
|
||||
for attr in _RESETTABLE_ATTRS:
|
||||
if attr in ('comment', 'regvalue'):
|
||||
continue
|
||||
setattr(entry, attr, None)
|
||||
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.
|
||||
entry.regvalue = (json.dumps(entry_dict['RegValue'])
|
||||
if 'RegValue' in entry_dict else None)
|
||||
for key, attr in _FLAG_TO_ATTR.items():
|
||||
setattr(entry, attr, bool(entry_dict.get(key)))
|
||||
# Multi-value filters -> child rows (replace, preserve order).
|
||||
entry.pctypes = [ManifestEntryPcType(sortorder=i, pctypevalue=value)
|
||||
for i, value in enumerate(entry_dict.get('PCTypes') or [])]
|
||||
entry.hostnames = [ManifestEntryHostname(sortorder=i, hostnamepattern=value)
|
||||
for i, value in enumerate(entry_dict.get('TargetHostnames') or [])]
|
||||
entry.machinenumbers = [
|
||||
ManifestEntryMachineNumber(sortorder=i, machinenumber=str(value))
|
||||
for i, value in enumerate(entry_dict.get('TargetMachineNumbers') or [])]
|
||||
# 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
|
||||
else:
|
||||
entry.inusecheck = None
|
||||
return entry
|
||||
|
||||
|
||||
def build_entry(entry_dict, sortorder):
|
||||
"""Build a new ManifestEntry (+ children) from one Applications[] entry."""
|
||||
entry = ManifestEntry(sortorder=sortorder)
|
||||
return populate_entry(entry, entry_dict)
|
||||
|
||||
|
||||
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)
|
||||
14
plugins/geenforce/manifest.json
Normal file
14
plugins/geenforce/manifest.json
Normal file
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"name": "geenforce",
|
||||
"display_name": "GE-Enforce",
|
||||
"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"}
|
||||
241
plugins/geenforce/migrations/versions/0001_geenforce_baseline.py
Normal file
241
plugins/geenforce/migrations/versions/0001_geenforce_baseline.py
Normal file
@@ -0,0 +1,241 @@
|
||||
"""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('appid', sa.Integer(), 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(
|
||||
'manifestenforcementreports',
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
sa.Column('hostname', sa.String(length=100), nullable=False),
|
||||
sa.Column('scopename', sa.String(length=64), nullable=False),
|
||||
sa.Column('phase', sa.String(length=16), nullable=False),
|
||||
sa.Column('appliedversion', sa.Integer(), nullable=True),
|
||||
sa.Column('enforcerversion', sa.String(length=20), nullable=True),
|
||||
sa.Column('installedcount', sa.Integer(), nullable=False),
|
||||
sa.Column('skippedcount', sa.Integer(), nullable=False),
|
||||
sa.Column('failedcount', sa.Integer(), nullable=False),
|
||||
sa.Column('filteredcount', sa.Integer(), nullable=False),
|
||||
sa.Column('status', sa.String(length=16), nullable=False),
|
||||
sa.Column('lastcheckin', sa.DateTime(), nullable=True),
|
||||
sa.Column('receivedat', sa.DateTime(), nullable=False),
|
||||
sa.Column('iscurrent', sa.Boolean(), nullable=False),
|
||||
sa.PrimaryKeyConstraint('reportid'),
|
||||
)
|
||||
op.create_index('idx_report_host', 'manifestenforcementreports',
|
||||
['hostname'])
|
||||
op.create_index('idx_report_current', 'manifestenforcementreports',
|
||||
['iscurrent'])
|
||||
op.create_index('idx_report_host_scope', 'manifestenforcementreports',
|
||||
['hostname', 'scopename', 'phase'])
|
||||
op.create_table(
|
||||
'manifestenforcementresults',
|
||||
sa.Column('resultid', sa.Integer(), nullable=False),
|
||||
sa.Column('reportid', sa.Integer(), nullable=False),
|
||||
sa.Column('entryname', sa.String(length=128), nullable=False),
|
||||
sa.Column('action', sa.String(length=16), nullable=False),
|
||||
sa.Column('selfhealed', sa.Boolean(), nullable=False),
|
||||
sa.Column('exitcode', sa.Integer(), nullable=True),
|
||||
sa.Column('message', sa.Text(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['reportid'],
|
||||
['manifestenforcementreports.reportid'],
|
||||
ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('resultid'),
|
||||
)
|
||||
op.create_index('idx_result_report', 'manifestenforcementresults',
|
||||
['reportid'])
|
||||
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('manifestenforcementresults')
|
||||
op.drop_table('manifestenforcementreports')
|
||||
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')
|
||||
45
plugins/geenforce/models/__init__.py
Normal file
45
plugins/geenforce/models/__init__.py
Normal file
@@ -0,0 +1,45 @@
|
||||
"""GE-Enforce plugin models."""
|
||||
|
||||
from .manifest import (
|
||||
ManifestScope,
|
||||
ManifestEntry,
|
||||
ManifestEntryPcType,
|
||||
ManifestEntryHostname,
|
||||
ManifestEntryMachineNumber,
|
||||
ManifestInUseCheck,
|
||||
ManifestInUseCheckProcess,
|
||||
ManifestPublishedVersion,
|
||||
ManifestPayload,
|
||||
ManifestEnforcementReport,
|
||||
ManifestEnforcementResult,
|
||||
PcTypeAlias,
|
||||
PHASES,
|
||||
ENTRY_TYPES,
|
||||
REG_TYPES,
|
||||
PAYLOAD_SOURCES,
|
||||
DETECTION_METHODS,
|
||||
APPLY_MODES,
|
||||
INUSE_BEHAVIORS,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
'ManifestScope',
|
||||
'ManifestEntry',
|
||||
'ManifestEntryPcType',
|
||||
'ManifestEntryHostname',
|
||||
'ManifestEntryMachineNumber',
|
||||
'ManifestInUseCheck',
|
||||
'ManifestInUseCheckProcess',
|
||||
'ManifestPublishedVersion',
|
||||
'ManifestPayload',
|
||||
'ManifestEnforcementReport',
|
||||
'ManifestEnforcementResult',
|
||||
'PcTypeAlias',
|
||||
'PHASES',
|
||||
'ENTRY_TYPES',
|
||||
'REG_TYPES',
|
||||
'PAYLOAD_SOURCES',
|
||||
'DETECTION_METHODS',
|
||||
'APPLY_MODES',
|
||||
'INUSE_BEHAVIORS',
|
||||
]
|
||||
353
plugins/geenforce/models/manifest.py
Normal file
353
plugins/geenforce/models/manifest.py
Normal file
@@ -0,0 +1,353 @@
|
||||
"""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 the ge-enforce-plugin proposal in the project wiki):
|
||||
- ONE wide entries table with an `entrytype` discriminator and nullable
|
||||
per-type columns. Not STI, not a JSON blob: the whole fleet is ~64 entries,
|
||||
so sparse columns are free and every field stays queryable + plain-SQL
|
||||
readable by IT.
|
||||
- Array order IS execution order, so `sortorder` is a real column and the
|
||||
contract; config-restore entries sit after their installer on purpose.
|
||||
- `computertypeid` / `measuringtooltypeid` are SOFT references (plain Integer,
|
||||
no DB foreign key) to other plugins' tables, which may not be installed.
|
||||
- Published manifests are immutable snapshots that freeze the rendered JSON
|
||||
document (`ManifestPublishedVersion`); the draft (`ManifestEntry`) is never
|
||||
served to a client.
|
||||
"""
|
||||
|
||||
from shopdb.api import db, BaseModel
|
||||
|
||||
|
||||
# Allowed enumerations, validated in the API/service layer (stored as strings
|
||||
# for portability, matching the rest of the codebase).
|
||||
PHASES = ('runtime', 'preinstall')
|
||||
ENTRY_TYPES = ('MSI', 'EXE', 'CMD', 'BAT', 'PS1', 'INF', 'File', 'Registry')
|
||||
REG_TYPES = ('String', 'DWord', 'QWord', 'MultiString', 'ExpandString', 'Binary')
|
||||
PAYLOAD_SOURCES = ('smb', 'http', 'inline')
|
||||
DETECTION_METHODS = ('Registry', 'File', 'FileVersion', 'Hash', 'MarkerFile',
|
||||
'ValueMatches', 'pnputil', 'Always')
|
||||
APPLY_MODES = ('Nightly', 'Immediate', 'ImmediateReboot')
|
||||
INUSE_BEHAVIORS = ('Defer', 'CloseAndReopen', 'ForceClose', 'ScheduleForReboot')
|
||||
|
||||
|
||||
class ManifestScope(BaseModel):
|
||||
"""One imaging PC type / manifest scope (the working/draft head)."""
|
||||
__tablename__ = 'manifestscopes'
|
||||
|
||||
scopeid = db.Column(db.Integer, primary_key=True)
|
||||
# 'common', 'gea-shopfloor-cmm', 'preinstall', ...
|
||||
scopename = db.Column(db.String(64), nullable=False)
|
||||
# 'runtime' (per-pctype manifest.json) or 'preinstall' (one flat manifest)
|
||||
phase = db.Column(db.String(16), nullable=False, default='runtime')
|
||||
# Soft refs (no FK): the plugins that own these tables are optional.
|
||||
computertypeid = db.Column(db.Integer, nullable=True)
|
||||
measuringtooltypeid = db.Column(db.Integer, nullable=True)
|
||||
manifestversion = db.Column(db.String(16), nullable=False, default='1.0')
|
||||
description = db.Column(db.String(255), nullable=True)
|
||||
# The manifest-level '_comment' (documentation), preserved verbatim so
|
||||
# export-to-share round-trips it. Excluded from behavioral parity.
|
||||
topcomment = db.Column(db.Text, nullable=True)
|
||||
iscommon = db.Column(db.Boolean, nullable=False, default=False)
|
||||
# Preinstall manifests carry a top-level 'Site' field.
|
||||
site = db.Column(db.String(100), nullable=True)
|
||||
|
||||
entries = db.relationship(
|
||||
'ManifestEntry', back_populates='scope',
|
||||
cascade='all, delete-orphan', order_by='ManifestEntry.sortorder',
|
||||
lazy='selectin')
|
||||
publishedversions = db.relationship(
|
||||
'ManifestPublishedVersion', back_populates='scope',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='ManifestPublishedVersion.versionnumber', lazy='dynamic')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('scopename', 'phase', name='uq_scope_name_phase'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ManifestScope {self.scopename}/{self.phase}>"
|
||||
|
||||
|
||||
class ManifestEntry(BaseModel):
|
||||
"""One Applications[] entry (draft). Wide table, entrytype discriminator."""
|
||||
__tablename__ = 'manifestentries'
|
||||
|
||||
entryid = db.Column(db.Integer, primary_key=True)
|
||||
scopeid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
name = db.Column(db.String(128), nullable=False)
|
||||
entrytype = db.Column(db.String(16), nullable=False)
|
||||
comment = db.Column(db.Text, nullable=True) # manifest '_comment'
|
||||
|
||||
# Type-specific payload references (nullable; validated per entrytype).
|
||||
installer = db.Column(db.String(255), nullable=True)
|
||||
installargs = db.Column(db.Text, nullable=True) # can run long
|
||||
scriptpath = db.Column(db.String(255), nullable=True) # 'Script'
|
||||
scriptargs = db.Column(db.String(255), nullable=True) # 'Args'
|
||||
sourcepath = db.Column(db.String(255), nullable=True) # 'Source'
|
||||
destination = db.Column(db.String(255), nullable=True)
|
||||
regpath = db.Column(db.String(255), nullable=True)
|
||||
regname = db.Column(db.String(128), nullable=True)
|
||||
# Raw JSON literal preserved verbatim (1 vs "1"); DWord typing depends on it.
|
||||
regvalue = db.Column(db.Text, nullable=True)
|
||||
regtype = db.Column(db.String(16), nullable=True)
|
||||
|
||||
# Payload transport + integrity (integrity hash is SEPARATE from detection).
|
||||
payloadsource = db.Column(db.String(8), nullable=False, default='smb')
|
||||
payloadref = db.Column(db.String(512), nullable=True)
|
||||
payloadsha256 = db.Column(db.String(64), nullable=True)
|
||||
|
||||
# Detection (decides whether the action fires / self-heals).
|
||||
detectionmethod = db.Column(db.String(16), nullable=True)
|
||||
detectionpath = db.Column(db.String(255), nullable=True)
|
||||
detectionname = db.Column(db.String(128), nullable=True)
|
||||
detectionvalue = db.Column(db.String(255), nullable=True)
|
||||
detectionpattern = db.Column(db.String(255), nullable=True)
|
||||
|
||||
# Gates + control.
|
||||
cmmversion = db.Column(db.String(16), nullable=True) # '_CmmVersion'
|
||||
logfile = db.Column(db.String(255), nullable=True)
|
||||
waittimeoutsec = db.Column(db.Integer, nullable=True)
|
||||
applymode = db.Column(db.String(16), nullable=True) # inert in engine today
|
||||
updatewindow = db.Column(db.String(11), nullable=True) # 'HH:MM-HH:MM', inert
|
||||
|
||||
# Optional CURATED link to a core Applications catalog row (soft ref, no FK
|
||||
# to keep the plugin decoupled). shopdb metadata only - NOT part of the
|
||||
# manifest JSON the engine reads, so it never affects enforcement or parity.
|
||||
appid = db.Column(db.Integer, nullable=True)
|
||||
|
||||
# Preinstall-only flags.
|
||||
preenrollment = db.Column(db.Boolean, nullable=False, default=False)
|
||||
killafterdetection = db.Column(db.Boolean, nullable=False, default=False)
|
||||
pctypesstrict = db.Column(db.Boolean, nullable=False, default=False)
|
||||
|
||||
scope = db.relationship('ManifestScope', back_populates='entries')
|
||||
pctypes = db.relationship(
|
||||
'ManifestEntryPcType', back_populates='entry',
|
||||
cascade='all, delete-orphan', order_by='ManifestEntryPcType.sortorder',
|
||||
lazy='selectin')
|
||||
hostnames = db.relationship(
|
||||
'ManifestEntryHostname', back_populates='entry',
|
||||
cascade='all, delete-orphan', order_by='ManifestEntryHostname.sortorder',
|
||||
lazy='selectin')
|
||||
machinenumbers = db.relationship(
|
||||
'ManifestEntryMachineNumber', back_populates='entry',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='ManifestEntryMachineNumber.sortorder', lazy='selectin')
|
||||
inusecheck = db.relationship(
|
||||
'ManifestInUseCheck', back_populates='entry', uselist=False,
|
||||
cascade='all, delete-orphan', lazy='selectin')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('scopeid', 'name', name='uq_entry_scope_name'),
|
||||
db.Index('idx_entry_scope_order', 'scopeid', 'sortorder'),
|
||||
)
|
||||
|
||||
def __repr__(self):
|
||||
return f"<ManifestEntry {self.name} ({self.entrytype})>"
|
||||
|
||||
|
||||
class ManifestEntryPcType(db.Model):
|
||||
"""One value of an entry's PCTypes filter (verbatim, incl. '*' and aliases)."""
|
||||
__tablename__ = 'manifestentrypctypes'
|
||||
|
||||
entrypctypeid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
pctypevalue = db.Column(db.String(64), nullable=False)
|
||||
|
||||
entry = db.relationship('ManifestEntry', back_populates='pctypes')
|
||||
|
||||
|
||||
class ManifestEntryHostname(db.Model):
|
||||
"""One value of an entry's TargetHostnames filter (wildcards kept verbatim)."""
|
||||
__tablename__ = 'manifestentryhostnames'
|
||||
|
||||
entryhostnameid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
hostnamepattern = db.Column(db.String(64), nullable=False)
|
||||
|
||||
entry = db.relationship('ManifestEntry', back_populates='hostnames')
|
||||
|
||||
|
||||
class ManifestEntryMachineNumber(db.Model):
|
||||
"""One value of an entry's TargetMachineNumbers filter."""
|
||||
__tablename__ = 'manifestentrymachinenumbers'
|
||||
|
||||
entrymachinenumberid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
machinenumber = db.Column(db.String(16), nullable=False)
|
||||
|
||||
entry = db.relationship('ManifestEntry', back_populates='machinenumbers')
|
||||
|
||||
|
||||
class ManifestInUseCheck(db.Model):
|
||||
"""The InUseCheck object on an entry (0..1), with its Processes[] children."""
|
||||
__tablename__ = 'manifestinusechecks'
|
||||
|
||||
inusecheckid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, unique=True)
|
||||
behavior = db.Column(db.String(20), nullable=False)
|
||||
|
||||
entry = db.relationship('ManifestEntry', back_populates='inusecheck')
|
||||
processes = db.relationship(
|
||||
'ManifestInUseCheckProcess', back_populates='inusecheck',
|
||||
cascade='all, delete-orphan',
|
||||
order_by='ManifestInUseCheckProcess.sortorder', lazy='selectin')
|
||||
|
||||
|
||||
class ManifestInUseCheckProcess(db.Model):
|
||||
"""One process in an InUseCheck's Processes[] list."""
|
||||
__tablename__ = 'manifestinusecheckprocesses'
|
||||
|
||||
inusecheckprocessid = db.Column(db.Integer, primary_key=True)
|
||||
inusecheckid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('manifestinusechecks.inusecheckid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
sortorder = db.Column(db.Integer, nullable=False, default=0)
|
||||
processname = db.Column(db.String(64), nullable=False)
|
||||
exepath = db.Column(db.String(255), nullable=True)
|
||||
# Null = engine default (10); do not bake the default into the row.
|
||||
gracefulclosetimeoutsec = db.Column(db.Integer, nullable=True)
|
||||
|
||||
inusecheck = db.relationship('ManifestInUseCheck', back_populates='processes')
|
||||
|
||||
|
||||
class ManifestPublishedVersion(db.Model):
|
||||
"""Immutable published snapshot: the frozen rendered JSON document.
|
||||
|
||||
The client is always served the `iscurrent` snapshot for a scope, never the
|
||||
live draft. Rollback flips `iscurrent`. Freezing the text (not row-mirroring)
|
||||
makes immutability structural and rollback a one-flag change.
|
||||
"""
|
||||
__tablename__ = 'manifestpublishedversions'
|
||||
|
||||
publishedversionid = db.Column(db.Integer, primary_key=True)
|
||||
scopeid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestscopes.scopeid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
versionnumber = db.Column(db.Integer, nullable=False)
|
||||
# MEDIUMTEXT on MySQL (a full scope with comments can exceed 64 KB TEXT).
|
||||
manifestjson = db.Column(db.Text(length=16777215), nullable=False)
|
||||
publishedat = db.Column(db.DateTime, nullable=False)
|
||||
publishedby = db.Column(db.Integer, nullable=True) # soft ref to users
|
||||
iscurrent = db.Column(db.Boolean, nullable=False, default=False)
|
||||
notes = db.Column(db.String(255), nullable=True)
|
||||
|
||||
scope = db.relationship('ManifestScope', back_populates='publishedversions')
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('scopeid', 'versionnumber',
|
||||
name='uq_published_scope_version'),
|
||||
)
|
||||
|
||||
|
||||
class ManifestPayload(db.Model):
|
||||
"""Inline payload bytes for payloadsource='inline' (small scripts/configs)."""
|
||||
__tablename__ = 'manifestpayloads'
|
||||
|
||||
payloadid = db.Column(db.Integer, primary_key=True)
|
||||
entryid = db.Column(
|
||||
db.Integer, db.ForeignKey('manifestentries.entryid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
filename = db.Column(db.String(255), nullable=False)
|
||||
contenttype = db.Column(db.String(128), nullable=True)
|
||||
payloadbytes = db.Column(db.LargeBinary(length=16777215), nullable=False)
|
||||
payloadsha256 = db.Column(db.String(64), nullable=False)
|
||||
uploadedat = db.Column(db.DateTime, nullable=False)
|
||||
|
||||
|
||||
class ManifestEnforcementReport(db.Model):
|
||||
"""One enforcement cycle reported by a PC (observed state).
|
||||
|
||||
Each cycle the client POSTs its result for a scope: the published version it
|
||||
actually applied (so shopdb knows whether the PC RECEIVED the latest update),
|
||||
the enforcer version, and the installed/skipped/failed/filtered counts. The
|
||||
latest report per (hostname, scopename, phase) carries `iscurrent`; older
|
||||
ones are history. Pairs desired state (the manifest) with observed state.
|
||||
"""
|
||||
__tablename__ = 'manifestenforcementreports'
|
||||
|
||||
reportid = db.Column(db.Integer, primary_key=True)
|
||||
hostname = db.Column(db.String(100), nullable=False, index=True)
|
||||
scopename = db.Column(db.String(64), nullable=False)
|
||||
phase = db.Column(db.String(16), nullable=False, default='runtime')
|
||||
# Published version the client actually ran; compare to the scope's current
|
||||
# published version to see whether this PC received the latest manifest.
|
||||
appliedversion = db.Column(db.Integer, nullable=True)
|
||||
enforcerversion = db.Column(db.String(20), nullable=True)
|
||||
installedcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
skippedcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
failedcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
filteredcount = db.Column(db.Integer, nullable=False, default=0)
|
||||
# 'ok' | 'failed' (any failure) | 'selfhealed' (drift corrected, no failure).
|
||||
status = db.Column(db.String(16), nullable=False, default='ok')
|
||||
lastcheckin = db.Column(db.DateTime, nullable=True) # PC-reported time
|
||||
receivedat = db.Column(db.DateTime, nullable=False) # server time
|
||||
iscurrent = db.Column(db.Boolean, nullable=False, default=True, index=True)
|
||||
|
||||
results = db.relationship(
|
||||
'ManifestEnforcementResult', back_populates='report',
|
||||
cascade='all, delete-orphan', lazy='selectin')
|
||||
|
||||
__table_args__ = (
|
||||
db.Index('idx_report_host_scope', 'hostname', 'scopename', 'phase'),
|
||||
)
|
||||
|
||||
|
||||
class ManifestEnforcementResult(db.Model):
|
||||
"""One entry's outcome within an enforcement cycle (self-heal detail)."""
|
||||
__tablename__ = 'manifestenforcementresults'
|
||||
|
||||
resultid = db.Column(db.Integer, primary_key=True)
|
||||
reportid = db.Column(
|
||||
db.Integer,
|
||||
db.ForeignKey('manifestenforcementreports.reportid', ondelete='CASCADE'),
|
||||
nullable=False, index=True)
|
||||
entryname = db.Column(db.String(128), nullable=False)
|
||||
# 'installed' (action fired - a self-heal when it should already be present),
|
||||
# 'skipped' (detected present), 'failed', 'filtered'.
|
||||
action = db.Column(db.String(16), nullable=False)
|
||||
# True when this install was a drift correction (self-heal), not a first
|
||||
# install. Client-supplied; defaults to whether the action installed.
|
||||
selfhealed = db.Column(db.Boolean, nullable=False, default=False)
|
||||
exitcode = db.Column(db.Integer, nullable=True)
|
||||
message = db.Column(db.Text, nullable=True) # warning / error text
|
||||
|
||||
report = db.relationship('ManifestEnforcementReport', back_populates='results')
|
||||
|
||||
|
||||
class PcTypeAlias(db.Model):
|
||||
"""Mirror of the engine lib's PCTypes alias graph (Install-FromManifest.ps1).
|
||||
|
||||
Rows sharing an `aliasgroup` are one alias set. Server-side resolve/validate
|
||||
only; the engine lib stays the single source of truth (never inverted).
|
||||
"""
|
||||
__tablename__ = 'pctypealiases'
|
||||
|
||||
aliasid = db.Column(db.Integer, primary_key=True)
|
||||
aliasgroup = db.Column(db.Integer, nullable=False, index=True)
|
||||
aliasname = db.Column(db.String(64), nullable=False)
|
||||
|
||||
__table_args__ = (
|
||||
db.UniqueConstraint('aliasgroup', 'aliasname', name='uq_alias_group_name'),
|
||||
)
|
||||
106
plugins/geenforce/parity.py
Normal file
106
plugins/geenforce/parity.py
Normal file
@@ -0,0 +1,106 @@
|
||||
"""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 0: scope-level fields (Version, Site) round-trip.
|
||||
firstdiff = None
|
||||
for key in ('Version', 'Site'):
|
||||
if str(original.get(key) or '') != str(rebuilt.get(key) or ''):
|
||||
firstdiff = (f'scope field {key}: '
|
||||
f'{original.get(key)!r} vs {rebuilt.get(key)!r}')
|
||||
break
|
||||
|
||||
scope_ok = firstdiff is None
|
||||
|
||||
# Check 1: field-identical, order-preserving.
|
||||
identical = 0
|
||||
if firstdiff is None and 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. Inject the
|
||||
# scope phase so PCTypesStrict is honored only for preinstall (see filters).
|
||||
profiles_same = 0
|
||||
for base_profile in fixtures:
|
||||
profile = {**base_profile, 'phase': phase}
|
||||
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 = (scope_ok
|
||||
and 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"}
|
||||
]
|
||||
194
plugins/geenforce/plugin.py
Normal file
194
plugins/geenforce/plugin.py
Normal file
@@ -0,0 +1,194 @@
|
||||
"""GE-Enforce manifest-store plugin.
|
||||
|
||||
Owns the imaging-PC-type scopes and their install manifests as shopdb data
|
||||
(see the ge-enforce-plugin proposal in the project wiki). 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, ManifestEnforcementReport,
|
||||
ManifestEnforcementResult, 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,
|
||||
ManifestEnforcementReport, ManifestEnforcementResult, 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'),
|
||||
('geenforce.report', 'Report enforcement results (client service token)',
|
||||
'geenforce'),
|
||||
]
|
||||
|
||||
def get_navigation_items(self) -> List[Dict]:
|
||||
"""Top-level sidebar section (GE-Enforce is a large operational surface,
|
||||
not a mere setting). The tabbed shell hosts Manifests + Reports."""
|
||||
return [
|
||||
{
|
||||
'name': 'GE-Enforce',
|
||||
'icon': 'shield',
|
||||
'route': '/geenforce/manifests',
|
||||
'position': 46,
|
||||
},
|
||||
]
|
||||
|
||||
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
|
||||
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
|
||||
343
plugins/geenforce/service.py
Normal file
343
plugins/geenforce/service.py
Normal file
@@ -0,0 +1,343 @@
|
||||
"""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 hashlib
|
||||
import os
|
||||
import tempfile
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func
|
||||
|
||||
from shopdb.api import db, Application
|
||||
|
||||
from .models import (
|
||||
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
||||
ManifestEnforcementResult, ManifestPayload,
|
||||
)
|
||||
from .importer import build_entry
|
||||
from .serializer import scope_to_json
|
||||
|
||||
|
||||
def _utcnow():
|
||||
return datetime.now(timezone.utc).replace(tzinfo=None)
|
||||
|
||||
|
||||
def _installed_app_model():
|
||||
"""Lazily import the computers plugin's ComputerInstalledApp.
|
||||
|
||||
The computers plugin is optional; importing it lazily (not at module load)
|
||||
keeps geenforce usable when computers is absent or disabled. Mirrors
|
||||
collector._computer_models. Returns the model class, or None if absent.
|
||||
"""
|
||||
try:
|
||||
from plugins.computers.models import ComputerInstalledApp
|
||||
return ComputerInstalledApp
|
||||
except ImportError:
|
||||
return 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 record_enforcement_report(payload):
|
||||
"""Record one PC's enforcement cycle (observed state). Upserts the latest
|
||||
report per (hostname, scopename, phase) and keeps prior ones as history.
|
||||
|
||||
Payload (all but hostname optional):
|
||||
hostname, scopename, phase, appliedversion, enforcerversion, lastcheckin,
|
||||
counts {installed, skipped, failed, filtered},
|
||||
results [{name, action, selfhealed, exitcode, message}]
|
||||
|
||||
Returns the new ManifestEnforcementReport (uncommitted).
|
||||
"""
|
||||
hostname = (payload.get('hostname') or '').strip()
|
||||
if not hostname:
|
||||
raise ValueError('hostname is required')
|
||||
scopename = (payload.get('scopename') or '').strip()
|
||||
phase = (payload.get('phase') or 'runtime').strip()
|
||||
counts = payload.get('counts') or {}
|
||||
results = payload.get('results') or []
|
||||
# Guard wrong JSON shapes: a bad type must be a clean 400, not a 500 from a
|
||||
# later .get()/iteration (the route maps ValueError to 400).
|
||||
if not isinstance(counts, dict):
|
||||
raise ValueError('counts must be an object')
|
||||
if not isinstance(results, list):
|
||||
raise ValueError('results must be a list')
|
||||
|
||||
failed = int(counts.get('failed', 0))
|
||||
installed = int(counts.get('installed', 0))
|
||||
# Derive status from EXPLICIT self-heal flags only, never the raw installed
|
||||
# count: every healthy cycle runs Always/no-detection scripts (asset report,
|
||||
# VNC firewall, EventSaver) that the engine counts as "installed", so keying
|
||||
# self-heal off installed>0 would mark the common scope selfhealed forever
|
||||
# and make 'ok' unreachable. A drift correction is one the client flags.
|
||||
if failed > 0:
|
||||
status = 'failed'
|
||||
elif any(r.get('selfhealed') for r in results):
|
||||
status = 'selfhealed'
|
||||
else:
|
||||
status = 'ok'
|
||||
|
||||
# Demote the prior current report for this host+scope. Hostname match is
|
||||
# case-insensitive to match the ilike read path - otherwise a PC reporting
|
||||
# its name in different casing would leave two iscurrent rows.
|
||||
ManifestEnforcementReport.query.filter(
|
||||
func.lower(ManifestEnforcementReport.hostname) == hostname.lower(),
|
||||
ManifestEnforcementReport.scopename == scopename,
|
||||
ManifestEnforcementReport.phase == phase,
|
||||
ManifestEnforcementReport.iscurrent == True
|
||||
).update({'iscurrent': False}, synchronize_session=False)
|
||||
|
||||
report = ManifestEnforcementReport(
|
||||
hostname=hostname,
|
||||
scopename=scopename,
|
||||
phase=phase,
|
||||
appliedversion=payload.get('appliedversion'),
|
||||
enforcerversion=payload.get('enforcerversion'),
|
||||
installedcount=installed,
|
||||
skippedcount=int(counts.get('skipped', 0)),
|
||||
failedcount=failed,
|
||||
filteredcount=int(counts.get('filtered', 0)),
|
||||
status=status,
|
||||
lastcheckin=_parse_dt(payload.get('lastcheckin')),
|
||||
receivedat=_utcnow(),
|
||||
iscurrent=True)
|
||||
for item in results:
|
||||
report.results.append(ManifestEnforcementResult(
|
||||
entryname=item.get('name', ''),
|
||||
action=item.get('action', ''),
|
||||
# Explicit flag only; do NOT infer from action == 'installed'
|
||||
# (Always/no-detection scripts install every cycle without being a
|
||||
# drift correction).
|
||||
selfhealed=bool(item.get('selfhealed', False)),
|
||||
exitcode=item.get('exitcode'),
|
||||
message=item.get('message')))
|
||||
db.session.add(report)
|
||||
return report
|
||||
|
||||
|
||||
def _parse_dt(value):
|
||||
if not value:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(str(value).replace('Z', '+00:00')).replace(
|
||||
tzinfo=None)
|
||||
except (ValueError, TypeError):
|
||||
return None
|
||||
|
||||
|
||||
def compliance_for_scope(scope):
|
||||
"""Fleet-install coverage for a scope's app-linked entries.
|
||||
|
||||
One row per ManifestEntry that carries a curated appid (unlinked entries
|
||||
are skipped), ordered by sortorder. Counts come from the computers plugin's
|
||||
ComputerInstalledApp (active rows only, one row per PC per app). Degrades
|
||||
gracefully with null counts when the computers plugin is absent.
|
||||
|
||||
Returns the response data dict (scopeid/scopename/phase/computersplugin/rows).
|
||||
"""
|
||||
linked = [e for e in sorted(scope.entries, key=lambda e: e.sortorder)
|
||||
if e.appid is not None]
|
||||
installedmodel = _installed_app_model()
|
||||
computerspresent = installedmodel is not None
|
||||
|
||||
# One grouped query for the whole scope's appid set (fleet is tiny).
|
||||
installedbyapp = {}
|
||||
if computerspresent and linked:
|
||||
appids = {e.appid for e in linked}
|
||||
counted = db.session.query(
|
||||
installedmodel.appid, func.count(installedmodel.id)
|
||||
).filter(
|
||||
installedmodel.isactive == True,
|
||||
installedmodel.appid.in_(appids),
|
||||
).group_by(installedmodel.appid).all()
|
||||
installedbyapp = {appid: count for appid, count in counted}
|
||||
|
||||
rows = []
|
||||
for entry in linked:
|
||||
app = db.session.get(Application, entry.appid)
|
||||
appname = app.appname if app else None
|
||||
expectedversion = (entry.detectionvalue
|
||||
if entry.detectionmethod == 'FileVersion' else None)
|
||||
|
||||
if not computerspresent:
|
||||
installedcount = None
|
||||
versionmatchcount = None
|
||||
coveragenote = ('computers plugin not installed; install counts '
|
||||
'unavailable')
|
||||
else:
|
||||
installedcount = installedbyapp.get(entry.appid, 0)
|
||||
if installedcount == 0:
|
||||
versionmatchcount = None if expectedversion is None else 0
|
||||
coveragenote = 'not installed on any collected PC'
|
||||
elif expectedversion is None:
|
||||
versionmatchcount = None
|
||||
method = entry.detectionmethod or 'none'
|
||||
coveragenote = (f'installed on {installedcount} PC(s); no '
|
||||
f'version target (detection is {method})')
|
||||
else:
|
||||
versionmatchcount = db.session.query(
|
||||
func.count(installedmodel.id)
|
||||
).filter(
|
||||
installedmodel.isactive == True,
|
||||
installedmodel.appid == entry.appid,
|
||||
installedmodel.installedversion == expectedversion,
|
||||
).scalar() or 0
|
||||
coveragenote = (f'{versionmatchcount} of {installedcount} '
|
||||
'collected PCs on the expected version')
|
||||
|
||||
rows.append({
|
||||
'entryid': entry.entryid,
|
||||
'entryname': entry.name,
|
||||
'appid': entry.appid,
|
||||
'appname': appname,
|
||||
'expectedversion': expectedversion,
|
||||
'installedcount': installedcount,
|
||||
'versionmatchcount': versionmatchcount,
|
||||
'coveragenote': coveragenote,
|
||||
})
|
||||
|
||||
return {
|
||||
'scopeid': scope.scopeid,
|
||||
'scopename': scope.scopename,
|
||||
'phase': scope.phase,
|
||||
'computersplugin': computerspresent,
|
||||
'rows': rows,
|
||||
}
|
||||
|
||||
|
||||
def store_inline_payload(entry, filename, contenttype, rawbytes):
|
||||
"""Replace the entry's inline payload with these bytes (uncommitted).
|
||||
|
||||
Computes payloadsha256, upserts the single ManifestPayload row (the table
|
||||
has no unique constraint on entryid, so the one-inline-payload-per-entry
|
||||
rule is enforced here), and points the entry at it (payloadsource='inline').
|
||||
Returns the ManifestPayload.
|
||||
"""
|
||||
sha = hashlib.sha256(rawbytes).hexdigest()
|
||||
ManifestPayload.query.filter_by(entryid=entry.entryid).delete()
|
||||
db.session.flush()
|
||||
payload = ManifestPayload(
|
||||
entryid=entry.entryid,
|
||||
filename=filename,
|
||||
contenttype=contenttype,
|
||||
payloadbytes=rawbytes,
|
||||
payloadsha256=sha,
|
||||
uploadedat=_utcnow())
|
||||
db.session.add(payload)
|
||||
entry.payloadsource = 'inline'
|
||||
entry.payloadref = filename
|
||||
entry.payloadsha256 = sha
|
||||
return payload
|
||||
|
||||
|
||||
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)
|
||||
|
||||
# Atomic write: a partial/failed write must never leave the live on-share
|
||||
# manifest (which every PC reads) truncated. Write a temp file in the same
|
||||
# directory, then rename over the target.
|
||||
targetdir = os.path.dirname(target)
|
||||
fd, tmppath = tempfile.mkstemp(dir=targetdir, suffix='.tmp')
|
||||
try:
|
||||
with os.fdopen(fd, 'w') as handle:
|
||||
handle.write(published.manifestjson)
|
||||
os.replace(tmppath, target)
|
||||
except Exception:
|
||||
if os.path.exists(tmppath):
|
||||
os.remove(tmppath)
|
||||
raise
|
||||
return target
|
||||
Reference in New Issue
Block a user