The stale-backup card could not be built as designed, and the reason is more important than the card. Dedup means an unchanged configuration writes no revision, so collectedat moves only on a CHANGE. A machine stable for six months has a six-month-old newest revision and is perfectly healthy. Keying a staleness card on revision age would have flagged most of the fleet - exactly the noise that makes a board worth ignoring. Underneath that: ShopDB could not distinguish those cases at all. On a no-op the server returned "unchanged" and wrote nothing, so "we checked yesterday and it matched" was discarded. That fact is the one thing a backup system must be able to prove, and the only record of it was a line in a log file on the PC. lastseenat records the check rather than the change. Touched on every matching post including the no-op; set on creation, since a new revision has by definition just been seen; backfilled from collectedat or createdat so existing rows start from the last moment the config can be PROVEN current, rather than from now - claiming a check that never happened would be worse than silence. The card keys on it, one row per CHAIN rather than per asset: a machine with two part markers can have one still reporting while the other stopped, and a per-asset view would report the machine as fine. It stays deliberately silent about assets never backed up, because whether one SHOULD be is a question only the manifest can answer, and guessing would list a hundred healthy machines. The rule lives in services/staleness.py rather than the route, so it is testable without an auth layer in the way - the same split retention.py uses. Threshold is backups_staledays, default 3, and 0 disables the card.
363 lines
13 KiB
Python
363 lines
13 KiB
Python
"""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 West Jefferson.
|
|
"""
|
|
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/<int:assetid>', 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/<int:assetid>/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/<int:backuprevisionid>', 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/<int:backuprevisionid>/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/<int:backuprevisionid>/diff', methods=['GET'])
|
|
@jwt_required()
|
|
@require_permission('backups.view')
|
|
def diff_revision(backuprevisionid):
|
|
"""Diff two revisions of the same asset+kind.
|
|
|
|
?against=<id> 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())
|