Adds a kind-pluggable backups plugin. Configuration captured from a PC is filed against the MACHINE it controls, with a revision history and download back to the native format. NTLARS/DNC is the first kind. Settings live in the controlling PC's registry but describe the machine, so revisions attach to the machine's asset and carry no foreign key to the PC: history survives a PC being replaced or deleted, and sourcehostname records the handover. Storage splits by kind. Parseable kinds store a dialect-neutral JSON projection in ShopDB and re-render on download; opaque vendor formats (part marker and similar) keep their bytes on the SFLD share with ShopDB holding metadata and the UNC pointer. Two .reg dialects exist in the wild: NTLARS's own Save... export omits the WOW6432Node path segment, scripted exports include it. Parsing strips whichever root matched, so a stored revision commits to neither and download offers both (NTLARS Load... by default, WOW6432Node for direct reg import). Getting this backwards is silent, so the dedup hash deliberately excludes sourcedialect and both dialects of one config dedup to a single revision. Dedup is load-bearing: the collector runs every GE-Enforce cycle across the fleet, so a revision is inserted only when the content hash differs from that asset's latest for that kind. A freshly imaged PC opens NTLARS with a blank General tab. Recording that would make an empty config the newest revision exactly when someone needs the last good one, so a blank MachineNo is rejected rather than accepted as a change. Two of the 320 known-good backups on the share already have that shape. DNC Info card summarises the latest revision on the machine page: General (Cnc, NcIF, HostType), eFocas, Serial, NTSHR when populated (only 18 of 147 machines), and MARK when the machine is a marker. MARK is gated on Cnc=MARKER or the ShopDB machine type, not on the MARK key having content: MARK carries serial defaults on 145 of 147 machines and DncPatterns reads YES on 103 including ordinary lathes, so neither identifies a marker. The info card is owned by the kind (BackupKind.infopanel/buildinfo) and served by a generic endpoint, so the expected successor to DNC ships its own card by adding a class rather than changing the plugin or the panel wiring. Also: schedule and retention settings with a prune that never drops the newest or the oldest revision, and scripts/import_ntlars_backups.py to seed history from the existing per-machine .reg files (144 of 147 resolve to assets). Codec verified against all 320 real backups: round-trips clean through both dialects. Bay-side generation verified on Windows against reg.exe export.
124 lines
5.2 KiB
Python
124 lines
5.2 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
|
|
|
|
|
|
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,
|
|
)
|
|
|
|
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)
|
|
|
|
__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,
|
|
'sourcehostname': self.sourcehostname,
|
|
'collectedat': self.collectedat.isoformat() if self.collectedat else None,
|
|
'createdat': self.createdat.isoformat() if self.createdat else None,
|
|
}
|
|
if includepayload:
|
|
data['payloadjson'] = self.payload
|
|
return data
|