Files
shopdb-flask/plugins/backups/models/backup.py
cproudlock 6c975a107c
Some checks failed
CI / backend (push) Failing after 8s
CI / naming (push) Successful in 2s
CI / frontend (push) Successful in 10s
CI / migrations-mysql (push) Failing after 7s
backups: record that a config was checked, not only that it changed
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.
2026-08-11 13:52:07 -04:00

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