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:
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