Files
shopdb-flask/plugins/backups/services/partmarkerinfo.py
cproudlock 512cc9be76 Let a share-backed backup say what is in it
A 'share' kind keeps its bytes on the file share and gives ShopDB only a hash
and a UNC path. That is right for an opaque vendor file, but it means the app
cannot answer the question a tech actually arrives with - what does this part
marker hold, and which pattern disappeared last month. Reading the share from
the app server is not an option: the download routes refuse to mount it on
purpose, because a permissions slip there becomes an unexplained empty panel.

So the collector now accepts an optional manifest alongside the hash: the
index, not the content. Names, sizes, per-file hashes, and the marking system
each pattern was authored for. Advisory throughout - a client that sends none
behaves exactly as before, and dedup still keys on contenthash.

The marker asset gains a Marker Contents card built from it: counts by type,
the controller family, and a diff against the previous snapshot in the same
source chain. Removed files are named rather than counted, because "1 removed"
only tells someone to go and look. Files edited in place are named too - the
file set can be identical while the content hash moved, and reporting no
change there would contradict the reason the revision exists.

The manifest is deliberately NOT covered by contenthash, which attests the
bytes on the share. A client that starts describing itself therefore posts an
unchanged hash, so a no-op backfills a manifest the row does not yet have -
otherwise the card would appear only on markers that had recently changed, and
stay hidden on every healthy one. The same no-op refreshes sharepath, so a
snapshot that moved on the share stops being pointed at where it used to be.

Manifests are validated at ingest rather than at render: a malformed one would
otherwise become the latest revision and fail every view of the asset page
until the content next changed, outliving the client bug that caused it.

sharedir() for the marker kind is overridden to the layout the site actually
uses, so what ShopDB displays and validates matches what the collector writes.
2026-08-25 13:06:49 -04:00

177 lines
7.8 KiB
Python

"""Marker Contents card.
What a part marker holds right now, on the marker's own asset page, plus what
changed since the previous snapshot.
WHY THIS EXISTS AT ALL
A 'share' kind keeps its bytes on SFLD and only a UNC pointer in ShopDB, so
the obvious way to list a snapshot's contents - read the directory - is
exactly what the read routes refuse to do: mounting the share in the app
server turns a permissions slip into an unexplained empty panel. Instead the
collector sends a MANIFEST alongside the hash, and this builds the card from
that. Small, structured, and it works from a server that cannot see the
share at all.
WHAT IT ANSWERS
"What patterns are on this marker" - the question a tech arrives with, and
today the only way to answer it is to walk to the bay and read the panel.
"What changed" - the added/removed diff against the previous snapshot. This
is the reason the snapshot layout exists: a pattern deleted from a
controller is invisible everywhere else until someone needs it. Here it is
dated, and the snapshot that still holds it is one row above in the history.
The manifest is written by Backup-PartMarker.ps1 and carries, per file: name,
bytes, sha256, and the marking system it was authored for.
"""
def _previousmanifest(assetid, currentid):
"""The manifest of the snapshot before this one, or None.
Scoped to the same source host, matching how dedup chains revisions: two
PCs reporting the same marker keep separate histories, and diffing across
them would report every file as added and removed at once.
The host comes from the REVISION's own column, not from a field inside the
manifest. Those can disagree - the column is what the collector recorded and
what retention chains on - and using the manifest's copy would silently diff
against the wrong chain, or against nothing at all.
"""
from shopdb.api import db
from ..models import BackupRevision
from .retention import samesource
current = db.session.get(BackupRevision, currentid)
if current is None:
return None, None
sourcehostname = current.sourcehostname
row = (db.session.query(BackupRevision)
.filter(BackupRevision.assetid == assetid,
BackupRevision.backupkind == 'partmarker',
BackupRevision.backuprevisionid < currentid,
samesource(sourcehostname))
.order_by(BackupRevision.backuprevisionid.desc())
.first())
if row is None:
return None, None
when = row.collectedat or row.createdat
try:
return row.payload, when
except ValueError:
# A corrupt neighbour must not take the card down - the current
# snapshot is still worth showing without its diff.
return None, None
def _hashes(manifest):
"""name -> sha256, for spotting a file edited in place."""
return {entry.get('name'): entry.get('sha256')
for entry in (manifest or {}).get('files', [])
if entry.get('name') and entry.get('sha256')}
def _names(manifest):
return {entry.get('name') for entry in (manifest or {}).get('files', [])
if entry.get('name')}
def _summarise(files):
"""Counts by extension, so 34 patterns and 12 settings read as two facts."""
groups = {}
for entry in files:
name = entry.get('name') or ''
ext = name.rsplit('.', 1)[-1].upper() if '.' in name else 'OTHER'
groups[ext] = groups.get(ext, 0) + 1
labels = {'PAT': 'Patterns', 'DAT': 'Settings files', 'TFF': 'Fonts'}
return [(labels.get(ext, ext), count) for ext, count in sorted(groups.items())]
def build(projection, assetid, revisionid=None, sourcehostname=None,
**options):
"""Build the card from a stored partmarker manifest.
Returns the keyvalue shape. An older revision predating manifests has no
projection at all, which renders as no card rather than an error - the
revision itself is still listed in the history panel.
"""
# "No manifest" and "a manifest listing nothing" are different facts. The
# first is an older revision predating manifests, which has no card. The
# second is a controller that has been wiped or replaced - precisely the
# disaster the Removed line exists to report - so it must render, with the
# diff intact.
if not projection:
return {'fields': [], 'sectioncount': 0}
manifest = projection
files = manifest.get('files') or []
fields = []
for label, count in _summarise(files):
fields.append({'label': label, 'value': str(count)})
total = sum(int(entry.get('bytes') or 0) for entry in files)
fields.append({'label': 'Total size', 'value': '{:,} bytes'.format(total)})
if manifest.get('family'):
fields.append({'label': 'Controller', 'value': manifest['family']})
# The heads patterns were authored for. Worth showing because they can
# DIFFER from the controller holding them - a TMC420 in the reference site
# holds both a TMP3200/470 and a TMP4210/420 pattern - and pushing a pattern
# back onto a system with a different head is a mistake worth catching
# before the restore, not after.
systems = manifest.get('systems') or []
if systems:
fields.append({'label': 'Authored for', 'value': ', '.join(systems)})
# From the revision's own column where the caller passes it: the manifest
# carries its own copy, and the two can disagree. The column is what the
# collector recorded and what the diff above is chained on, so naming a
# different PC here would contradict the comparison shown beside it.
capturedfrom = sourcehostname or manifest.get('sourcehost')
if capturedfrom:
fields.append({'label': 'Captured from', 'value': capturedfrom})
# The diff. Only meaningful with a predecessor: on a marker's first snapshot
# every file would otherwise read as "added", which says nothing.
if revisionid:
previous, when = _previousmanifest(assetid, revisionid)
if previous is not None:
before, now = _names(previous), _names(manifest)
added = sorted(now - before)
removed = sorted(before - now)
# Same name, different bytes. A revision exists only because the
# content hash moved, so if the file SET is identical then something
# was edited in place - and saying "no files added or removed" would
# be a flat contradiction of why this row exists. The per-file
# sha256 is in the manifest, so the edited files can be named.
beforehashes = _hashes(previous)
changed = sorted(name for name, digest in _hashes(manifest).items()
if name in beforehashes and beforehashes[name] != digest)
# Date the window. Retention prunes middle revisions, so "previous"
# can be months older than the row before it in the history, and an
# undated diff would quietly attribute an old removal to this
# capture.
label = 'Since {}'.format(when.strftime('%Y-%m-%d')) if when else 'Since previous'
if added:
fields.append({'label': 'Added', 'value': ', '.join(added)})
if removed:
# The one the whole design exists for. Named, not counted -
# "1 removed" would send someone to the share to find out which.
fields.append({'label': 'Removed', 'value': ', '.join(removed)})
if changed:
fields.append({'label': 'Changed', 'value': ', '.join(changed)})
if not (added or removed or changed):
fields.append({'label': label, 'value': 'No files added, removed or changed'})
else:
fields.append({'label': 'Compared with', 'value': label.replace('Since ', '')})
return {'fields': fields, 'sectioncount': 1}