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