"""Backups API. Read + download only. Revisions are created exclusively through the ADR-006 collector endpoint (POST /api/collector/backups), so there is deliberately no create route here: a hand-posted "backup" that never came off a machine would poison the history this feature exists to provide. Downloads differ by storage backend. 'shopdb' kinds re-render from the stored projection, which is what lets one revision produce either .reg dialect. 'share' kinds are not streamed - ShopDB returns the UNC path for the tech to open, because requiring the app server to mount the SFLD share would turn a permissions slip into an unexplained empty download. """ from datetime import timezone from zoneinfo import ZoneInfo from flask import Blueprint, current_app, request, Response from flask_jwt_extended import jwt_required from shopdb.api import ( db, Asset, success_response, error_response, ErrorCodes, require_permission, ) from ..models import BackupRevision from ..models.backup import _utciso from ..services.registry import REGISTRY, getkind from ..services.retention import samesource backups_bp = Blueprint('backups', __name__) _DEFAULTTZ = 'America/New_York' def _sitezone(): """Site-configured IANA zone (settings key site_timezone). Same lookup the notifications plugin uses. A stored timestamp is UTC, so anything rendered server-side has to be converted or it shows the wrong wall clock for the site - four hours out at the reference site. """ from shopdb.api import Setting row = Setting.query.filter_by(key='site_timezone').first() name = row.value if row and row.value else _DEFAULTTZ try: return ZoneInfo(name) except Exception: return ZoneInfo(_DEFAULTTZ) def _label(revision): """Panel list title: kind-agnostic, readable at a glance. Rendered in the SITE zone, not UTC: this string is baked server-side and the client cannot correct it afterwards. """ when = revision.collectedat or revision.createdat if not when: return 'unknown time' local = when.replace(tzinfo=timezone.utc).astimezone(_sitezone()) stamp = local.strftime('%Y-%m-%d %H:%M') if revision.sourcefilename: return '{} - {}'.format(stamp, revision.sourcefilename) return stamp def _summary(revision, islatest=False): data = revision.to_dict() data['label'] = _label(revision) data['islatest'] = islatest kind = getkind(revision.backupkind) data['formats'] = kind.formats() if kind else [] return data @backups_bp.route('/kinds', methods=['GET']) @jwt_required() @require_permission('backups.view') def list_kinds(): """Registered backup kinds, for UI that needs to enumerate them.""" return success_response([ { 'key': kind.key, 'displayname': kind.displayname, 'storagebackend': kind.storagebackend, 'assettypes': list(kind.assettypes), 'formats': kind.formats(), } for kind in REGISTRY.values() ]) @backups_bp.route('/asset/', methods=['GET']) @jwt_required() @require_permission('backups.view') def asset_revisions(assetid): """Revision history for one asset, newest first. This is the asset-panel endpoint. Optional ?kind= narrows to a single kind, which is how each per-kind panel scopes itself. """ asset = db.session.get(Asset, assetid) if asset is None: return error_response(ErrorCodes.NOT_FOUND, 'Asset not found', http_code=404) query = db.session.query(BackupRevision).filter( BackupRevision.assetid == assetid) kindkey = (request.args.get('kind') or '').strip().lower() if kindkey: if getkind(kindkey) is None: return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown kind: {}'.format(kindkey)) query = query.filter(BackupRevision.backupkind == kindkey) revisions = query.order_by(BackupRevision.backuprevisionid.desc()).all() # "Latest" is per kind, not per asset, so an unfiltered listing still marks # the current revision of each kind correctly. seen = set() out = [] for revision in revisions: islatest = revision.backupkind not in seen seen.add(revision.backupkind) out.append(_summary(revision, islatest=islatest)) return success_response(out) @backups_bp.route('/asset//info', methods=['GET']) @jwt_required() @require_permission('backups.view') def asset_info(assetid): """A kind's 'at a glance' card, built from its LATEST revision. Generic across kinds: the requested kind owns both the panel declaration and the payload (see BackupKind.infopanel / buildinfo), so a successor to NTLARS/DNC gets its own card without a new endpoint. Empty when the machine has no revision of that kind yet, which the generic renderer turns into the panel's empty text. """ kindkey = (request.args.get('kind') or 'ntlars').strip().lower() kind = getkind(kindkey) if kind is None: return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown kind: {}'.format(kindkey)) revision = (db.session.query(BackupRevision) .filter(BackupRevision.assetid == assetid, BackupRevision.backupkind == kindkey) .order_by(BackupRevision.backuprevisionid.desc()) .first()) if revision is None: return success_response({'fields': [], 'sectioncount': 0}) data = kind.buildinfo( revision.payload, assetid, partmarkertypes=current_app.config.get('BACKUPS_PARTMARKER_TYPES')) data['backuprevisionid'] = revision.backuprevisionid data['collectedat'] = _utciso(revision.collectedat) return success_response(data) @backups_bp.route('/revisions/', methods=['GET']) @jwt_required() @require_permission('backups.view') def get_revision(backuprevisionid): """One revision, including its parsed payload where there is one.""" revision = db.session.get(BackupRevision, backuprevisionid) if revision is None: return error_response(ErrorCodes.NOT_FOUND, 'Revision not found', http_code=404) data = revision.to_dict(includepayload=True) data['label'] = _label(revision) kind = getkind(revision.backupkind) data['formats'] = kind.formats() if kind else [] data['displayname'] = kind.displayname if kind else revision.backupkind return success_response(data) @backups_bp.route('/revisions//download', methods=['GET']) @jwt_required() @require_permission('backups.download') def download_revision(backuprevisionid): """Render a revision back to its native file format. ?format=ntlars (default for NTLARS) omits WOW6432Node - the form the NTLARS Load... button expects ?format=wow6432node includes WOW6432Node - for `reg import` on 64-bit """ revision = db.session.get(BackupRevision, backuprevisionid) if revision is None: return error_response(ErrorCodes.NOT_FOUND, 'Revision not found', http_code=404) kind = getkind(revision.backupkind) if kind is None: return error_response(ErrorCodes.VALIDATION_ERROR, 'Unknown kind: {}'.format(revision.backupkind)) if revision.storagebackend == 'share': # Not an error: the file exists, ShopDB just is not the one serving it. return success_response({ 'storagebackend': 'share', 'sharepath': revision.sharepath, 'sourcefilename': revision.sourcefilename, 'message': 'This backup lives on the SFLD share. Open the path directly.', }) formats = kind.formats() formatid = (request.args.get('format') or '').strip().lower() if not formatid: formatid = formats[0]['id'] if formats else '' if not any(f['id'] == formatid for f in formats): return error_response( ErrorCodes.VALIDATION_ERROR, 'Unknown format {!r} for kind {}'.format(formatid, kind.key)) asset = db.session.get(Asset, revision.assetid) assetnumber = asset.assetnumber if asset else str(revision.assetid) when = revision.collectedat or revision.createdat comments = [ '{} backup from ShopDB'.format(kind.displayname), 'Asset: {}'.format(assetnumber), 'Captured: {}'.format(when.strftime('%Y-%m-%d %H:%M:%S') if when else 'unknown'), 'Source PC: {}'.format(revision.sourcehostname or 'unknown'), 'Revision: {} ({})'.format(revision.backuprevisionid, (revision.contenthash or '')[:12]), ] try: raw, ext, mimetype = kind.render(revision.payload, formatid, comments=comments) except ValueError as exc: return error_response(ErrorCodes.VALIDATION_ERROR, str(exc)) # Named for the MACHINE, not the revision or the kind: a tech restoring bay # 3204 wants 3204.reg, matching how the per-machine backups on the share # have always been named. sourcefilename is deliberately not reused here - # a seeded revision carries "3204.reg" already and appending an extension # to it produced "3204.reg.reg". filename = '{}{}'.format(assetnumber, ext) if formatid == 'wow6432node': # The two dialects must not collide in a downloads folder, and the # suffix says which one will import correctly outside NTLARS. filename = '{}-wow6432node{}'.format(assetnumber, ext) return Response( raw, mimetype=mimetype, headers={'Content-Disposition': 'attachment; filename="{}"'.format(filename)}, ) @backups_bp.route('/revisions//diff', methods=['GET']) @jwt_required() @require_permission('backups.view') def diff_revision(backuprevisionid): """Diff two revisions of the same asset+kind. ?against= picks the comparison revision; default is the immediately preceding one, which answers "what changed" without the user choosing. """ revision = db.session.get(BackupRevision, backuprevisionid) if revision is None: return error_response(ErrorCodes.NOT_FOUND, 'Revision not found', http_code=404) if revision.storagebackend != 'shopdb': return error_response( ErrorCodes.VALIDATION_ERROR, 'Kind {} stores opaque files and cannot be diffed'.format( revision.backupkind)) againstid = request.args.get('against', type=int) if againstid: other = db.session.get(BackupRevision, againstid) else: # The previous revision FROM THE SAME PC. Several PCs can share a # machine number, and diffing across them reported one device's COM port # as a change on another, which is not a change at all. other = (db.session.query(BackupRevision) .filter(BackupRevision.assetid == revision.assetid, BackupRevision.backupkind == revision.backupkind, samesource(revision.sourcehostname), BackupRevision.backuprevisionid < revision.backuprevisionid) .order_by(BackupRevision.backuprevisionid.desc()) .first()) if other is None: return success_response({ 'backuprevisionid': revision.backuprevisionid, 'againstid': None, 'changes': [], 'message': 'No earlier revision to compare against.', }) changes = _diffprojections(other.payload, revision.payload) return success_response({ 'backuprevisionid': revision.backuprevisionid, 'againstid': other.backuprevisionid, 'changes': changes, 'changecount': len(changes), }) def _flatten(projection): """{(subkey, valuename): (type, data)} from a stored projection.""" flat = {} for key in (projection or {}).get('keys', []): path = key.get('path', '') for name, entry in (key.get('values') or {}).items(): flat[(path, name)] = (entry.get('type'), entry.get('data')) return flat def _diffprojections(old, new): oldflat = _flatten(old) newflat = _flatten(new) changes = [] for ref in sorted(set(oldflat) | set(newflat)): path, name = ref before = oldflat.get(ref) after = newflat.get(ref) if before == after: continue if before is None: change = 'added' elif after is None: change = 'removed' else: change = 'changed' changes.append({ 'keypath': path, 'valuename': name, 'change': change, 'before': None if before is None else before[1], 'after': None if after is None else after[1], 'beforetype': None if before is None else before[0], 'aftertype': None if after is None else after[0], }) return changes @backups_bp.route('/dashboard/stale', methods=['GET']) @jwt_required() @require_permission('backups.view') def dashboard_stale(): """Chains whose backup has stopped running. Thin: the rule lives in services/staleness.py, where it is testable without an auth layer in the way. Keyed on the last CONFIRMED check, never on the last change - dedup means an unchanged config writes no revision, so a card keyed on revision age would flag most of a healthy fleet. """ from ..services.staleness import stalechains return success_response(stalechains())