"""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 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 ..services.registry import REGISTRY, getkind backups_bp = Blueprint('backups', __name__) def _label(revision): """Panel list title: kind-agnostic, readable at a glance.""" when = revision.collectedat or revision.createdat stamp = when.strftime('%Y-%m-%d %H:%M') if when else 'unknown time' 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'] = (revision.collectedat.isoformat() if revision.collectedat else None) 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: other = (db.session.query(BackupRevision) .filter(BackupRevision.assetid == revision.assetid, BackupRevision.backupkind == revision.backupkind, 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