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.
160 lines
7.1 KiB
Python
160 lines
7.1 KiB
Python
"""Backup revision model.
|
|
|
|
One row per DISTINCT configuration snapshot of an asset. The collector runs
|
|
every GE-Enforce cycle across the whole fleet, so the write path dedupes on
|
|
contenthash: a row appears only when a setting actually changed. That is what
|
|
turns a high-frequency collector into a readable revision history.
|
|
|
|
Two storage backends, chosen by the kind (see services/registry.py):
|
|
|
|
'shopdb' Parsed structured config lives in payloadjson. The original file
|
|
is not kept because it re-renders exactly from the projection
|
|
(NTLARS .reg is the motivating case), which also lets one stored
|
|
revision render into more than one dialect on download.
|
|
|
|
'share' Opaque vendor formats that have no useful JSON representation
|
|
(part marker files and similar). Bytes stay on the SFLD share and
|
|
the row carries sharepath plus enough metadata to list, dedupe and
|
|
link to them. ShopDB never needs to parse these.
|
|
|
|
contenthash is sha256 over the canonical form of whatever is authoritative for
|
|
the backend: the canonical JSON for 'shopdb', the raw file bytes for 'share'.
|
|
"""
|
|
|
|
import json
|
|
from datetime import datetime
|
|
|
|
from shopdb.api import db
|
|
|
|
|
|
def _utciso(value):
|
|
"""Naive-UTC datetime -> ISO string marked as UTC, or None.
|
|
|
|
Every datetime this plugin stores is naive UTC. Serialising it without the
|
|
Z leaves the receiver to guess, and JavaScript guesses browser-local.
|
|
"""
|
|
return value.isoformat() + 'Z' if value else None
|
|
|
|
|
|
class BackupRevision(db.Model):
|
|
"""A single point-in-time configuration snapshot of an asset."""
|
|
|
|
__tablename__ = 'backuprevisions'
|
|
|
|
backuprevisionid = db.Column(db.Integer, primary_key=True)
|
|
|
|
# The asset the config BELONGS to, which is not always the asset it was
|
|
# collected from. NTLARS settings live in the controlling PC's registry but
|
|
# describe the machine, so the collector reports a machine number and the
|
|
# kind resolves it to the machine's asset. sourcehostname records the PC it
|
|
# actually came off.
|
|
assetid = db.Column(
|
|
db.Integer,
|
|
db.ForeignKey('assets.assetid', ondelete='CASCADE'),
|
|
nullable=False,
|
|
index=True,
|
|
)
|
|
|
|
# Read-only view of the owning asset, so a revision can name its own
|
|
# download <machinenumber>.reg. No cascade or backref: the asset side must
|
|
# not gain a dependency on this plugin (ADR-014 lean builds).
|
|
asset = db.relationship('Asset', lazy='joined', viewonly=True)
|
|
|
|
backupkind = db.Column(db.String(50), nullable=False, index=True)
|
|
storagebackend = db.Column(db.String(20), nullable=False, default='shopdb')
|
|
|
|
# sha256 of the canonical authoritative form. Dedup key together with
|
|
# (assetid, backupkind).
|
|
contenthash = db.Column(db.String(64), nullable=False, index=True)
|
|
|
|
# Populated for storagebackend='shopdb' only. MEDIUMTEXT holding serialized
|
|
# JSON rather than a native JSON column, matching geenforce.manifestjson.
|
|
# Text also means the bytes come back exactly as written, so the canonical
|
|
# key ordering the codec produces survives the round trip - a native JSON
|
|
# column would renormalize it and make diffs between revisions unstable.
|
|
payloadjson = db.Column(db.Text(length=16777215), nullable=True)
|
|
|
|
# Populated for storagebackend='share' only. Full UNC path.
|
|
sharepath = db.Column(db.String(500), nullable=True)
|
|
|
|
# Original filename incl. extension. Vendor tools reject a renamed file, so
|
|
# downloads hand back exactly this name.
|
|
sourcefilename = db.Column(db.String(255), nullable=True)
|
|
bytesize = db.Column(db.Integer, nullable=True)
|
|
|
|
# Which PC produced it, and when it was read off that PC (not when ShopDB
|
|
# stored it - a share-drop fallback can arrive much later).
|
|
sourcehostname = db.Column(db.String(255), nullable=True)
|
|
collectedat = db.Column(db.DateTime, nullable=True)
|
|
|
|
createdat = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
|
|
|
# When this configuration was last CONFIRMED still current, which is not the
|
|
# same as when it last changed. Dedup means an unchanged config writes no
|
|
# revision, so collectedat only ever moves on a change - a machine whose
|
|
# settings have been stable for six months has a six-month-old newest
|
|
# revision and is perfectly healthy. Without this column ShopDB cannot tell
|
|
# that machine from one whose backup stopped running six months ago, which
|
|
# is the one question a backup system has to be able to answer. Touched on
|
|
# every matching post, including the no-op that writes nothing else.
|
|
lastseenat = db.Column(db.DateTime, nullable=True)
|
|
|
|
__table_args__ = (
|
|
db.Index('ixbackuprevisionsassetkind', 'assetid', 'backupkind'),
|
|
)
|
|
|
|
@property
|
|
def payload(self):
|
|
"""Decoded payloadjson, or None for opaque (share-backed) kinds."""
|
|
if not self.payloadjson:
|
|
return None
|
|
try:
|
|
return json.loads(self.payloadjson)
|
|
except (ValueError, TypeError) as exc:
|
|
# Do NOT return None here. A corrupt row would then look identical
|
|
# to an opaque share-backed revision, and the download route would
|
|
# fail somewhere further along with an unrelated error. Name the
|
|
# actual problem and the row it is in.
|
|
raise ValueError(
|
|
'backuprevision {} has unreadable payloadjson: {}'.format(
|
|
self.backuprevisionid, exc))
|
|
|
|
@payload.setter
|
|
def payload(self, value):
|
|
if value is None:
|
|
self.payloadjson = None
|
|
else:
|
|
self.payloadjson = json.dumps(value, sort_keys=True,
|
|
separators=(',', ':'))
|
|
|
|
def to_dict(self, includepayload=False):
|
|
data = {
|
|
'backuprevisionid': self.backuprevisionid,
|
|
'assetid': self.assetid,
|
|
'backupkind': self.backupkind,
|
|
'storagebackend': self.storagebackend,
|
|
'contenthash': self.contenthash,
|
|
'shorthash': (self.contenthash or '')[:12],
|
|
'sharepath': self.sharepath,
|
|
'sourcefilename': self.sourcefilename,
|
|
'bytesize': self.bytesize,
|
|
# The machine number. Carried on the revision so the UI can name a
|
|
# download <machinenumber>.reg without a second round trip.
|
|
'assetnumber': self.asset.assetnumber if self.asset else None,
|
|
'sourcehostname': self.sourcehostname,
|
|
# Both columns hold NAIVE UTC (see collectedat above), so the wire
|
|
# format says so with a trailing Z. Without it JavaScript's
|
|
# `new Date('2026-08-07T12:00:00')` parses the string as
|
|
# BROWSER-LOCAL and the timestamp silently shifts by the viewer's
|
|
# offset before any site-timezone formatting is applied.
|
|
'collectedat': _utciso(self.collectedat),
|
|
# When the config was last CONFIRMED current, versus when it last
|
|
# changed. The history view needs both or a stable machine looks
|
|
# abandoned.
|
|
'lastseenat': _utciso(self.lastseenat),
|
|
'createdat': _utciso(self.createdat),
|
|
}
|
|
if includepayload:
|
|
data['payloadjson'] = self.payload
|
|
return data
|