Files
shopdb-flask/plugins/backups/services/staleness.py

90 lines
3.5 KiB
Python

"""Which backup chains have stopped running.
Split from the route for the same reason retention.py is: the rule here is the
interesting part and it should be testable without an auth layer in the way.
THE RULE, and it is not the obvious one. Dedup means an unchanged configuration
writes no revision, so `collectedat` moves only when something CHANGES. A
machine whose settings have been stable for six months has a six-month-old
newest revision and is entirely healthy. Staleness is therefore measured from
`lastseenat` - when the config was last CONFIRMED current, recorded on every
matching post including the no-op that writes nothing else.
Keyed on the CHAIN (asset, kind, source PC), because that is the unit that fails
independently. 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.
"""
from datetime import datetime, timedelta
from shopdb.api import db, Asset
from ..models import BackupRevision
DEFAULTSTALEDAYS = 3
def staledays():
"""Configured threshold; 0 or negative disables the card entirely."""
from shopdb.api import Setting
setting = Setting.query.filter_by(key='backups_staledays').first()
if setting and (setting.value or '').strip():
try:
return int(setting.value)
except (TypeError, ValueError):
pass
return DEFAULTSTALEDAYS
def latestperchain():
"""Newest revision of every (asset, kind, source) chain."""
latest = {}
for revision in (BackupRevision.query
.order_by(BackupRevision.backuprevisionid.asc()).all()):
latest[(revision.assetid, revision.backupkind,
revision.sourcehostname)] = revision
return latest
def stalechains(days=None, limit=50):
"""Chains whose last confirmed check is older than the threshold.
Deliberately silent about assets that have NEVER been backed up. Whether one
SHOULD be is a question only the manifest can answer - most machines carry
no NTLARS at all - and guessing would list a hundred healthy assets, which
is the noise that makes a board worth ignoring. That is the
desired-versus-observed card, which needs the manifest resolver.
"""
days = staledays() if days is None else days
if days <= 0:
return []
now = datetime.utcnow()
cutoff = now - timedelta(days=days)
rows = []
for revision in latestperchain().values():
# NULL means never confirmed since the column existed, and it is NOT
# substituted with collectedat. That substitution is what the first
# version did, and on a real fleet it reported every machine whose
# config had simply been stable for months as a stopped backup - the
# whole board lit up and said the site was broken when it was fine.
# A chain becomes measurable the first time its PC posts; until then
# this card says nothing about it, which is the truth.
seen = revision.lastseenat
if seen is None or seen >= cutoff:
continue
asset = db.session.get(Asset, revision.assetid)
rows.append({
'assetid': revision.assetid,
'assetnumber': asset.assetnumber if asset else str(revision.assetid),
'backupkind': revision.backupkind,
'sourcehostname': revision.sourcehostname,
'lastseenat': seen.isoformat() + 'Z',
'quietdays': (now - seen).days,
})
rows.sort(key=lambda r: -r['quietdays'])
return rows[:limit]