238 lines
9.2 KiB
Python
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())
|