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.
360 lines
15 KiB
Python
360 lines
15 KiB
Python
"""Backup kind registry.
|
|
|
|
A "kind" is one category of configuration backup (NTLARS/DNC registry, part
|
|
marker, UDC, CMM ...). Each kind declares three things the rest of the plugin
|
|
needs and nothing else:
|
|
|
|
where its bytes live 'shopdb' (parsed into payloadjson) or 'share'
|
|
(opaque vendor file left on the SFLD share)
|
|
how to resolve an asset the collector reports an identifier; the kind turns
|
|
it into the assetid the revision belongs to
|
|
how to render downloads one or more output formats
|
|
|
|
Adding a kind is a class plus one REGISTRY entry. Nothing else in the plugin
|
|
knows kind names.
|
|
"""
|
|
|
|
import hashlib
|
|
import json
|
|
|
|
from . import ntlars as ntlarscodec
|
|
|
|
# Root of the opaque-backup tree on the site's file share. Connected PCs write
|
|
# here directly (they need share creds - a SYSTEM process hitting a UNC path
|
|
# without them gets an access-denied that Test-Path reports as "not found").
|
|
#
|
|
# EMPTY BY DESIGN (ADR-015). This used to ship one site's own file server
|
|
# as the default, which put one site's internal topology in a bundled plugin and
|
|
# in the public mirror, and silently pointed a second site at a server it cannot
|
|
# reach. The live value is the backups_shareroot setting; unset, the share kinds
|
|
# have no conventional location to offer and say so rather than composing a path
|
|
# from somebody else's hostname.
|
|
DEFAULTSHAREROOT = ''
|
|
|
|
|
|
def canonicalhash(projection):
|
|
"""sha256 over the SEMANTIC content only. Dedup key for 'shopdb' kinds.
|
|
|
|
Deliberately hashes just schema + keys, excluding sourcedialect. The same
|
|
settings exported through NTLARS's Save... button and through a scripted
|
|
reg export parse to identical keys but differing sourcedialect; hashing the
|
|
whole projection would record that as a change and produce a spurious
|
|
revision every time the collection route changed. Storage is
|
|
dialect-neutral, so the dedup key has to be too.
|
|
"""
|
|
subset = {
|
|
'schema': projection.get('schema'),
|
|
'keys': projection.get('keys'),
|
|
}
|
|
blob = json.dumps(subset, sort_keys=True, separators=(',', ':'))
|
|
return hashlib.sha256(blob.encode('utf-8')).hexdigest()
|
|
|
|
|
|
def byteshash(raw):
|
|
"""sha256 over raw bytes. Dedup key for 'share' kinds."""
|
|
return hashlib.sha256(raw).hexdigest()
|
|
|
|
|
|
def markerforsource(sourcehostname):
|
|
"""Asset id of the part marker the reporting PC drives, or None.
|
|
|
|
A part-marker PC's config describes ITS marker, not the operation the
|
|
marker serves. Several markers can serve one operation - 0613, 0615 and
|
|
WJPRT each do - so filing by machine number put several devices' configs in
|
|
one history where they overwrote each other. The computers collector gives
|
|
each marker PC a marker asset (`collector:partmarker`), and that is what a
|
|
backup from that PC belongs to.
|
|
|
|
Falls through to None, and so to the machine number, whenever anything is
|
|
missing: no hostname, no computers or machines plugin on a lean build
|
|
(ADR-014), or a marker PC that has not reported to the computers collector
|
|
yet. Filing under the operation is the old behaviour and still better than
|
|
rejecting the backup.
|
|
"""
|
|
from shopdb.api import db, AssetRelationship, RelationshipType
|
|
|
|
hostname = (sourcehostname or '').strip()
|
|
if not hostname:
|
|
return None
|
|
try:
|
|
from plugins.computers.models import Computer
|
|
from plugins.computers.plugin import PARTMARKER_LINK_ORIGIN
|
|
except ImportError:
|
|
return None
|
|
|
|
computer = Computer.query.filter(Computer.hostname.ilike(hostname)).first()
|
|
if computer is None or not computer.assetid:
|
|
return None
|
|
|
|
controls = RelationshipType.query.filter_by(
|
|
relationshiptype='controls').first()
|
|
if controls is None:
|
|
return None
|
|
|
|
link = (db.session.query(AssetRelationship)
|
|
.filter(AssetRelationship.sourceassetid == computer.assetid,
|
|
AssetRelationship.relationshiptypeid ==
|
|
controls.relationshiptypeid,
|
|
AssetRelationship.label == PARTMARKER_LINK_ORIGIN,
|
|
AssetRelationship.isactive.is_(True))
|
|
.first())
|
|
return link.targetassetid if link else None
|
|
|
|
|
|
class BackupKind:
|
|
"""Base class. Subclasses override what applies to them."""
|
|
|
|
key = None
|
|
displayname = None
|
|
storagebackend = 'shopdb'
|
|
assettypes = ['*']
|
|
# Text shown when the kind has no revisions for an asset. None means the
|
|
# panel HIDES entirely instead (the generic list renderer shows a panel
|
|
# only when it has rows or declares empty text). Default to hiding: a kind
|
|
# applies to an asset TYPE, but whether a given asset ever has this kind of
|
|
# backup is a property of the individual machine. A part marker panel on
|
|
# every one of 144 machines is noise, not information.
|
|
emptytext = None
|
|
|
|
def parse(self, raw):
|
|
"""Opaque kinds return None; parseable kinds return the projection."""
|
|
return None
|
|
|
|
def formats(self):
|
|
"""Downloadable formats: [{'id','label','ext','mimetype'}]."""
|
|
return []
|
|
|
|
def render(self, projection, formatid, comments=None):
|
|
"""Return (bytes, extension, mimetype) for a 'shopdb' kind."""
|
|
raise NotImplementedError
|
|
|
|
def resolveassetid(self, payload):
|
|
"""Map a collector payload to the assetid the backup belongs to."""
|
|
raise NotImplementedError
|
|
|
|
def infopanel(self):
|
|
"""Optional 'at a glance' card built from this kind's LATEST revision.
|
|
|
|
Returns an ADR-010 panel dict, or None when the kind has no summary
|
|
worth surfacing. Declared by the kind rather than hardcoded in the
|
|
plugin so a successor technology (NTLARS/DNC is expected to give way to
|
|
Shopfloor Connect) ships its own card by adding a class, without
|
|
touching the plugin or the panel wiring.
|
|
"""
|
|
return None
|
|
|
|
def buildinfo(self, projection, assetid, **options):
|
|
"""Build this kind's info-card payload. Only called when infopanel()."""
|
|
return {'fields': [], 'sectioncount': 0}
|
|
|
|
def sharedir(self, machinetype, identifier, shareroot=None):
|
|
"""UNC directory a 'share' kind's files are expected under.
|
|
|
|
Advisory only - the authoritative path is the sharepath the collector
|
|
reports, since the PC is what actually wrote the file. This builds the
|
|
conventional location for display and for validating a reported path.
|
|
|
|
Returns '' when no share root is configured. A path built on an empty
|
|
root is not a lesser answer, it is a wrong one - it would render as a
|
|
relative path and validate a reported path against nothing.
|
|
"""
|
|
root = shareroot or DEFAULTSHAREROOT
|
|
if not root:
|
|
return ''
|
|
return '{}\\{}\\{}\\{}'.format(
|
|
root, machinetype or 'unknown', identifier or 'unknown', self.key)
|
|
|
|
|
|
class NtlarsKind(BackupKind):
|
|
"""NTLARS / DNC registry settings.
|
|
|
|
Collected from the CONTROLLING PC's registry but belongs to the MACHINE:
|
|
the settings describe how to talk to that machine's controller, so they
|
|
follow the machine when a PC is swapped. resolveassetid therefore keys on
|
|
the reported machine number, not the hostname.
|
|
"""
|
|
|
|
key = 'ntlars'
|
|
displayname = 'NTLARS / DNC Settings'
|
|
storagebackend = 'shopdb'
|
|
assettypes = ['machine']
|
|
|
|
@staticmethod
|
|
def embeddedmachineno(projection):
|
|
"""The MachineNo NTLARS itself is configured with (General tab)."""
|
|
for key in (projection or {}).get('keys', []):
|
|
if (key.get('path') or '').lower() == 'general':
|
|
entry = (key.get('values') or {}).get('MachineNo') or {}
|
|
return str(entry.get('data') or '').strip()
|
|
return ''
|
|
|
|
def parse(self, raw):
|
|
"""Parse, then refuse to record an unconfigured NTLARS install.
|
|
|
|
A freshly imaged PC opens NTLARS with a blank General tab (no MachineNo,
|
|
CNC, Host or Interface type) until a tech restores the config. Storing
|
|
that would make an empty config the newest revision at exactly the
|
|
moment someone needs the last good one - so a blank MachineNo is
|
|
rejected as "nothing worth backing up" rather than accepted as a change.
|
|
Two of the 320 known-good backups on the share already have this shape.
|
|
"""
|
|
projection = ntlarscodec.parse(raw)
|
|
if not self.embeddedmachineno(projection):
|
|
raise ValueError(
|
|
'NTLARS General\\MachineNo is empty - unconfigured install, '
|
|
'refusing to record it as a revision')
|
|
return projection
|
|
|
|
def formats(self):
|
|
return [
|
|
{
|
|
'id': 'ntlars',
|
|
'label': 'NTLARS Load... (.reg)',
|
|
'ext': '.reg',
|
|
'mimetype': 'application/octet-stream',
|
|
'hint': 'Restore with the Load... button in the NTLARS settings dialog.',
|
|
},
|
|
{
|
|
'id': 'wow6432node',
|
|
'label': 'Direct reg import (.reg, WOW6432Node)',
|
|
'ext': '.reg',
|
|
'mimetype': 'application/octet-stream',
|
|
'hint': 'Use when importing outside NTLARS on a 64-bit machine.',
|
|
},
|
|
]
|
|
|
|
def render(self, projection, formatid, comments=None):
|
|
if formatid not in ('ntlars', 'wow6432node'):
|
|
raise ValueError('unknown format for ntlars kind: {}'.format(formatid))
|
|
raw = ntlarscodec.render(projection, dialect=formatid, comments=comments)
|
|
return raw, '.reg', 'application/octet-stream'
|
|
|
|
def infopanel(self):
|
|
return {
|
|
'id': 'backups-dncinfo',
|
|
'title': 'DNC Info',
|
|
'assettypes': ['machine'],
|
|
'endpoint': '/api/backups/asset/{assetid}/info?kind=ntlars',
|
|
# tabs, not keyvalue: General / eFocas / Serial / NTSHR / MARK are
|
|
# one card the tech switches between, rather than a 30-row wall.
|
|
# No 'empty' key - the renderer hides a panel with no sections, so
|
|
# a machine with no NTLARS revision has no DNC Info card at all.
|
|
'render': 'tabs',
|
|
# Above the history panels: this answers the question a tech
|
|
# arrives with, while history is for the rarer restore case.
|
|
'position': 38,
|
|
}
|
|
|
|
def buildinfo(self, projection, assetid, **options):
|
|
from . import dncinfo
|
|
return dncinfo.build(projection, assetid,
|
|
partmarkertypes=options.get('partmarkertypes'))
|
|
|
|
def resolveassetid(self, payload):
|
|
from shopdb.api import db, Asset
|
|
|
|
marker = markerforsource(payload.get('sourcehostname'))
|
|
if marker is not None:
|
|
return marker, None
|
|
|
|
machinenumber = (payload.get('machinenumber') or '').strip()
|
|
if not machinenumber:
|
|
return None, 'no machinenumber in payload'
|
|
asset = db.session.query(Asset).filter(
|
|
Asset.assetnumber == machinenumber).first()
|
|
if asset is None:
|
|
return None, 'no asset with assetnumber {!r}'.format(machinenumber)
|
|
return asset.assetid, None
|
|
|
|
|
|
class PartMarkerKind(BackupKind):
|
|
"""Telesis part marker configuration.
|
|
|
|
Opaque vendor filetype with no useful JSON representation, so ShopDB stores
|
|
metadata and a UNC pointer while the file itself stays on the share. The
|
|
original filename and extension are preserved because the vendor tool
|
|
rejects a renamed file.
|
|
"""
|
|
|
|
key = 'partmarker'
|
|
displayname = 'Part Marker Configuration'
|
|
storagebackend = 'share'
|
|
# Marker assets are core type 'machine', like the other 144 machines - but
|
|
# only a marker ever has a partmarker revision, and the list renderer hides
|
|
# a panel with no rows and no empty text. So declaring 'machine' shows the
|
|
# panel on markers and nowhere else, with no special-casing.
|
|
#
|
|
# This was deliberately EMPTY while backups were filed against the operation
|
|
# number, when a partmarker card would have appeared on every machine and
|
|
# said nothing. Since a marker PC's backup is filed against its own marker
|
|
# asset (see markerforsource), the panel has a home of its own.
|
|
assettypes = ['machine']
|
|
|
|
def sharedir(self, machinetype, identifier, shareroot=None):
|
|
"""<root>\\partmarker\\<marker asset>\\ - the SITE's layout, which
|
|
inverts the base class's <root>\\<machinetype>\\<identifier>\\<kind>.
|
|
|
|
Overridden rather than left to the default so what ShopDB displays and
|
|
validates matches what the collector actually writes. Still advisory:
|
|
the authoritative path is the sharepath on each revision, which is what
|
|
keeps older snapshots resolvable after a PC swap renames the asset.
|
|
|
|
Snapshots live in timestamped subdirectories BELOW this - one per
|
|
capture, so a pattern deleted from the controller stays recoverable
|
|
from the snapshot that still holds it.
|
|
"""
|
|
root = shareroot or DEFAULTSHAREROOT
|
|
if not root:
|
|
return ''
|
|
return '{}\\{}\\{}'.format(root, self.key, identifier or 'unknown')
|
|
|
|
def infopanel(self):
|
|
"""Contents of the latest snapshot, on the marker's own asset page.
|
|
|
|
A 'share' kind stores no bytes in ShopDB, so this is built from the
|
|
manifest the collector sends alongside the hash. Without it the app
|
|
server would have to mount SFLD to say what a snapshot contains, which
|
|
the read routes refuse to do on purpose.
|
|
"""
|
|
return {
|
|
'id': 'backups-partmarker-contents',
|
|
'title': 'Marker Contents',
|
|
'assettypes': ['machine'],
|
|
'endpoint': '/api/backups/asset/{assetid}/info?kind=partmarker',
|
|
'render': 'keyvalue',
|
|
# Above the history panel: "what is on this marker" is the question
|
|
# a tech arrives with; the history is for the rarer restore.
|
|
'position': 39,
|
|
}
|
|
|
|
def buildinfo(self, projection, assetid, **options):
|
|
from . import partmarkerinfo
|
|
return partmarkerinfo.build(
|
|
projection, assetid,
|
|
revisionid=options.get('revisionid'),
|
|
sourcehostname=options.get('sourcehostname'))
|
|
|
|
def resolveassetid(self, payload):
|
|
from shopdb.api import db, Asset
|
|
|
|
marker = markerforsource(payload.get('sourcehostname'))
|
|
if marker is not None:
|
|
return marker, None
|
|
|
|
identifier = (payload.get('machinenumber')
|
|
or payload.get('assetnumber') or '').strip()
|
|
if not identifier:
|
|
return None, 'no machinenumber/assetnumber in payload'
|
|
asset = db.session.query(Asset).filter(
|
|
Asset.assetnumber == identifier).first()
|
|
if asset is None:
|
|
return None, 'no asset with assetnumber {!r}'.format(identifier)
|
|
return asset.assetid, None
|
|
|
|
|
|
REGISTRY = {k.key: k for k in (NtlarsKind(), PartMarkerKind())}
|
|
|
|
|
|
def getkind(key):
|
|
return REGISTRY.get((key or '').strip().lower())
|