A deep Fable review of the real manifest corpus (READ-ONLY reference) showed the manifests are an ENFORCEMENT PROGRAM, not an application inventory, and that auto-seeding the Applications catalog from entry Type + Name was wrong: - The catalog ALREADY tracks these apps from the classic-shopdb migration, with version histories (PC - DMIS, UDC x11 versions, eMX / eDNC, CLM, CSF, Oracle Database, FormTracePak). Seeding from manifest labels created DUPLICATES under different names (PC-DMIS 2016 vs PC - DMIS; eDNC (bundles NTLARS) vs eMX / eDNC; OpenText HostExplorer ShopFloor vs CSF). It also misclassified config drops (eMxInfo.txt) as apps and could never match a PC's reported ARP name. So the seed-applications command + service are removed. Properly linking manifest entries to the EXISTING catalog is a curated feature, not label-scraping. Two REAL bugs the review found are fixed and kept: - Report status (R4): every healthy cycle runs Always/no-detection scripts the engine counts as "installed", so keying self-heal off installed>0 marked the common scope selfhealed forever and made 'ok' unreachable. Status now derives from explicit per-entry self-heal flags only; the stored flag no longer infers from action=='installed'; the client kit doc reflects it. - PCTypesStrict (R5): the runtime engine has no strict handling (preinstall runner only). filters.matches_pctype now applies strict only when phase == 'preinstall'; simulate + parity thread the scope phase through; the strict test uses a preinstall scope. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
218 lines
8.1 KiB
Python
218 lines
8.1 KiB
Python
"""DB-touching operations for the manifest store.
|
|
|
|
Kept out of the CLI and routes so both share one implementation:
|
|
- `replace_scope_draft` imports/re-imports a scope's DRAFT entries WITHOUT
|
|
touching its published-version history (re-import is idempotent + safe).
|
|
- `publish_scope` freezes the current draft into a new immutable snapshot.
|
|
- `rollback_scope` / `export_scope_to_share` round out the publish lifecycle.
|
|
"""
|
|
|
|
import os
|
|
import tempfile
|
|
from datetime import datetime, timezone
|
|
|
|
from sqlalchemy import func
|
|
|
|
from shopdb.api import db
|
|
|
|
from .models import (
|
|
ManifestScope, ManifestPublishedVersion, ManifestEnforcementReport,
|
|
ManifestEnforcementResult,
|
|
)
|
|
from .importer import build_entry
|
|
from .serializer import scope_to_json
|
|
|
|
|
|
def _utcnow():
|
|
return datetime.now(timezone.utc).replace(tzinfo=None)
|
|
|
|
|
|
def replace_scope_draft(scopename, phase, manifest):
|
|
"""Create/refresh a scope and REPLACE its draft entries. Published versions
|
|
are left untouched. Returns the scope (uncommitted)."""
|
|
scope = ManifestScope.query.filter_by(
|
|
scopename=scopename, phase=phase).first()
|
|
if not scope:
|
|
scope = ManifestScope(scopename=scopename, phase=phase)
|
|
db.session.add(scope)
|
|
scope.manifestversion = str(manifest.get('Version', '1.0'))
|
|
scope.topcomment = manifest.get('_comment')
|
|
scope.site = manifest.get('Site')
|
|
scope.iscommon = (scopename == 'common')
|
|
|
|
for entry in list(scope.entries):
|
|
db.session.delete(entry)
|
|
db.session.flush()
|
|
|
|
for i, entry_dict in enumerate(manifest.get('Applications') or []):
|
|
scope.entries.append(build_entry(entry_dict, i))
|
|
return scope
|
|
|
|
|
|
def publish_scope(scopename, phase, notes=None, publishedby=None):
|
|
"""Freeze the current draft into a new published snapshot. Returns the
|
|
version number (uncommitted)."""
|
|
scope = ManifestScope.query.filter_by(
|
|
scopename=scopename, phase=phase).first()
|
|
if not scope:
|
|
raise ValueError(f'No scope {scopename}/{phase}')
|
|
|
|
text = scope_to_json(scope)
|
|
maxversion = db.session.query(
|
|
func.max(ManifestPublishedVersion.versionnumber)
|
|
).filter_by(scopeid=scope.scopeid).scalar() or 0
|
|
|
|
ManifestPublishedVersion.query.filter_by(
|
|
scopeid=scope.scopeid, iscurrent=True
|
|
).update({'iscurrent': False})
|
|
|
|
db.session.add(ManifestPublishedVersion(
|
|
scopeid=scope.scopeid,
|
|
versionnumber=maxversion + 1,
|
|
manifestjson=text,
|
|
publishedat=_utcnow(),
|
|
publishedby=publishedby,
|
|
iscurrent=True,
|
|
notes=notes))
|
|
return maxversion + 1
|
|
|
|
|
|
def rollback_scope(scopename, phase, versionnumber):
|
|
"""Make an older published version current again (uncommitted)."""
|
|
scope = ManifestScope.query.filter_by(
|
|
scopename=scopename, phase=phase).first()
|
|
if not scope:
|
|
raise ValueError(f'No scope {scopename}/{phase}')
|
|
target = ManifestPublishedVersion.query.filter_by(
|
|
scopeid=scope.scopeid, versionnumber=versionnumber).first()
|
|
if not target:
|
|
raise ValueError(f'No version {versionnumber} for {scopename}')
|
|
ManifestPublishedVersion.query.filter_by(
|
|
scopeid=scope.scopeid, iscurrent=True
|
|
).update({'iscurrent': False})
|
|
target.iscurrent = True
|
|
return versionnumber
|
|
|
|
|
|
def 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 []
|
|
|
|
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 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
|