Files
shopdb-flask/plugins/backups/services/registry.py

238 lines
9.2 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
# Default root of the opaque-backup tree on the SFLD share. Connected PCs write
# here directly (they need SFLD creds - a SYSTEM process hitting a UNC path
# without them gets an access-denied that Test-Path reports as "not found").
#
# This is the WEST JEFFERSON path and is only a DEFAULT: the live value is the
# backups_shareroot setting, because a bundled plugin in a multi-site product
# must not hardcode one site's file server.
DEFAULTSHAREROOT = r'\\tsgwp00525.wjs.geaerospace.net\shared\dt\shopfloor\backups'
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()
class BackupKind:
"""Base class. Subclasses override what applies to them."""
key = None
displayname = None
storagebackend = 'shopdb'
assettypes = ['*']
# Human note shown on the panel when there is nothing yet.
emptytext = 'No backups on record.'
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.
"""
return '{}\\{}\\{}\\{}'.format(
shareroot or DEFAULTSHAREROOT,
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']
emptytext = 'No NTLARS settings captured yet.'
@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',
'render': 'keyvalue',
'empty': 'No NTLARS settings captured for this machine yet.',
# 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
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'
assettypes = ['machine', 'measuring_tool']
emptytext = 'No part marker backups on record.'
def resolveassetid(self, payload):
from shopdb.api import db, Asset
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())